From 826258787bad944824e4e42e921c2953cef62dc8 Mon Sep 17 00:00:00 2001 From: Daniel Blittschau Date: Sat, 16 May 2020 16:46:07 -0500 Subject: [PATCH 01/58] Fix outdated AdminLTE link in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4dd56ba1a..53c62f2b1 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ In addition to our standard nest of supported games, our community is constantly ## Credits This software would not be possible without the work of other open-source authors who provide tools such as: -[Ace Editor](https://ace.c9.io), [AdminLTE](https://almsaeedstudio.com), [Animate.css](http://daneden.github.io/animate.css/), [AnsiUp](https://github.com/drudru/ansi_up), [Async.js](https://github.com/caolan/async), +[Ace Editor](https://ace.c9.io), [AdminLTE](https://adminlte.io), [Animate.css](http://daneden.github.io/animate.css/), [AnsiUp](https://github.com/drudru/ansi_up), [Async.js](https://github.com/caolan/async), [Bootstrap](http://getbootstrap.com), [Bootstrap Notify](http://bootstrap-notify.remabledesigns.com), [Chart.js](http://www.chartjs.org), [FontAwesome](http://fontawesome.io), [FontAwesome Animations](https://github.com/l-lin/font-awesome-animation), [jQuery](http://jquery.com), [Laravel](https://laravel.com), [Lodash](https://lodash.com), [Select2](https://select2.github.io), [Socket.io](http://socket.io), [Socket.io File Upload](https://github.com/vote539/socketio-file-upload), [SweetAlert](http://t4t5.github.io/sweetalert), From a8e701daa79261c4a86161637b7b821e0b4522da Mon Sep 17 00:00:00 2001 From: Jakob Date: Mon, 13 Jul 2020 19:56:35 +0200 Subject: [PATCH 02/58] add .env.example to panel.tar.gz in automated release --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61df3a539..06582c0e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: - name: Create release archive run: | rm -rf node_modules/ test/ codecov.yml CODE_OF_CONDUCT.md CONTRIBUTING.md phpunit.dusk.xml phpunit.xml Vagrantfile - tar -czf panel.tar.gz * + tar -czf panel.tar.gz * .env.example - name: Extract changelog id: extract_changelog From 6df54b7149de43b74dd8b5bba0c4eb48b7eab65c Mon Sep 17 00:00:00 2001 From: Vilhelm Prytz Date: Tue, 14 Jul 2020 00:52:35 +0200 Subject: [PATCH 03/58] Remove unused import importing SpinnerOverlay is redundant since it is not used --- resources/scripts/components/dashboard/ServerRow.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/resources/scripts/components/dashboard/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index 39d2278f5..ee756e504 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -3,7 +3,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faServer, faEthernet, faMicrochip, faMemory, faHdd } from '@fortawesome/free-solid-svg-icons'; import { Link } from 'react-router-dom'; import { Server } from '@/api/server/getServer'; -import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage'; import { bytesToHuman, megabytesToHuman } from '@/helpers'; import tw from 'twin.macro'; From 6c0d3083489cd254a141601d773cd2869385fb42 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Tue, 14 Jul 2020 20:48:41 -0700 Subject: [PATCH 04/58] Paginate servers on frontend; closes #2106 --- resources/scripts/api/getServers.ts | 10 ++- .../dashboard/DashboardContainer.tsx | 40 +++++---- .../components/dashboard/ServerRow.tsx | 3 +- .../dashboard/search/SearchModal.tsx | 2 +- .../components/elements/Pagination.tsx | 87 +++++++++++++++++++ 5 files changed, 122 insertions(+), 20 deletions(-) create mode 100644 resources/scripts/components/elements/Pagination.tsx diff --git a/resources/scripts/api/getServers.ts b/resources/scripts/api/getServers.ts index 8dd8ed22a..492898f70 100644 --- a/resources/scripts/api/getServers.ts +++ b/resources/scripts/api/getServers.ts @@ -1,13 +1,19 @@ import { rawDataToServerObject, Server } from '@/api/server/getServer'; import http, { getPaginationSet, PaginatedResult } from '@/api/http'; -export default (query?: string, includeAdmin?: boolean): Promise> => { +interface QueryParams { + query?: string; + page?: number; + includeAdmin?: boolean; +} + +export default ({ query, page = 1, includeAdmin = false }: QueryParams): Promise> => { return new Promise((resolve, reject) => { http.get('/api/client', { params: { - include: [ 'allocation' ], type: includeAdmin ? 'all' : undefined, 'filter[name]': query, + page, }, }) .then(({ data }) => resolve({ diff --git a/resources/scripts/components/dashboard/DashboardContainer.tsx b/resources/scripts/components/dashboard/DashboardContainer.tsx index 692ac2ed1..60c18cb12 100644 --- a/resources/scripts/components/dashboard/DashboardContainer.tsx +++ b/resources/scripts/components/dashboard/DashboardContainer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { Server } from '@/api/server/getServer'; import getServers from '@/api/getServers'; import ServerRow from '@/components/dashboard/ServerRow'; @@ -11,15 +11,17 @@ import Switch from '@/components/elements/Switch'; import tw from 'twin.macro'; import useSWR from 'swr'; import { PaginatedResult } from '@/api/http'; +import Pagination from '@/components/elements/Pagination'; export default () => { const { clearFlashes, clearAndAddHttpError } = useFlash(); + const [ page, setPage ] = useState(1); const { rootAdmin } = useStoreState(state => state.user.data!); - const [ showAdmin, setShowAdmin ] = usePersistedState('show_all_servers', false); + const [ includeAdmin, setIncludeAdmin ] = usePersistedState('show_all_servers', false); const { data: servers, error } = useSWR>( - [ '/api/client/servers', showAdmin ], - () => getServers(undefined, showAdmin) + [ '/api/client/servers', includeAdmin, page ], + () => getServers({ includeAdmin, page }), ); useEffect(() => { @@ -32,26 +34,34 @@ export default () => { {rootAdmin &&

- {showAdmin ? 'Showing all servers' : 'Showing your servers'} + {includeAdmin ? 'Showing all servers' : 'Showing your servers'}

setShowAdmin(s => !s)} + defaultChecked={includeAdmin} + onChange={() => setIncludeAdmin(s => !s)} />
} {!servers ? : - servers.items.length > 0 ? - servers.items.map((server, index) => ( - 0 ? tw`mt-2` : undefined}/> - )) - : -

- There are no servers associated with your account. -

+ + {({ items }) => ( + items.length > 0 ? + items.map((server, index) => ( + 0 ? tw`mt-2` : undefined} + /> + )) + : +

+ There are no servers associated with your account. +

+ )} +
} ); diff --git a/resources/scripts/components/dashboard/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index 39d2278f5..d68744c49 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -1,9 +1,8 @@ import React, { useEffect, useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faServer, faEthernet, faMicrochip, faMemory, faHdd } from '@fortawesome/free-solid-svg-icons'; +import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons'; import { Link } from 'react-router-dom'; import { Server } from '@/api/server/getServer'; -import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage'; import { bytesToHuman, megabytesToHuman } from '@/helpers'; import tw from 'twin.macro'; diff --git a/resources/scripts/components/dashboard/search/SearchModal.tsx b/resources/scripts/components/dashboard/search/SearchModal.tsx index 1461b8013..75eff1bfd 100644 --- a/resources/scripts/components/dashboard/search/SearchModal.tsx +++ b/resources/scripts/components/dashboard/search/SearchModal.tsx @@ -57,7 +57,7 @@ export default ({ ...props }: Props) => { setSubmitting(false); clearFlashes('search'); - getServers(term) + getServers({ query: term }) .then(servers => setServers(servers.items.filter((_, index) => index < 5))) .catch(error => { console.error(error); diff --git a/resources/scripts/components/elements/Pagination.tsx b/resources/scripts/components/elements/Pagination.tsx new file mode 100644 index 000000000..1ca625fd4 --- /dev/null +++ b/resources/scripts/components/elements/Pagination.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { PaginatedResult } from '@/api/http'; +import tw from 'twin.macro'; +import styled from 'styled-components/macro'; +import Button from '@/components/elements/Button'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faAngleDoubleLeft, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons'; + +interface RenderFuncProps { + items: T[]; + isLastPage: boolean; + isFirstPage: boolean; +} + +interface Props { + data: PaginatedResult; + showGoToLast?: boolean; + showGoToFirst?: boolean; + onPageSelect: (page: number) => void; + children: (props: RenderFuncProps) => React.ReactNode; +} + +const Block = styled(Button)` + ${tw`p-0 w-10 h-10`} + + &:not(:last-of-type) { + ${tw`mr-2`}; + } +`; + +function Pagination ({ data: { items, pagination }, onPageSelect, children }: Props) { + const isFirstPage = pagination.currentPage === 1; + const isLastPage = pagination.currentPage >= pagination.totalPages; + + const pages = []; + + // Start two spaces before the current page. If that puts us before the starting page default + // to the first page as the starting point. + const start = Math.max(pagination.currentPage - 2, 1); + const end = Math.min(pagination.totalPages, pagination.currentPage + 5); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + return ( + <> + {children({ items, isFirstPage, isLastPage })} + {(pages.length > 1) && +
+ {(pages[0] > 1 && !isFirstPage) && + onPageSelect(1)} + > + + + } + { + pages.map(i => ( + onPageSelect(i)} + > + {i} + + )) + } + {(pages[4] < pagination.totalPages && !isLastPage) && + onPageSelect(pagination.totalPages)} + > + + + } +
+ } + + ); +} + +export default Pagination; From 78c76d6df435e2f9a435b6efe10eee3d9c5d7b70 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Tue, 14 Jul 2020 21:16:38 -0700 Subject: [PATCH 05/58] Ensure errors from daemon are wrapped correctly --- .../Api/Client/Servers/CommandController.php | 13 +- .../Api/Client/Servers/FileController.php | 18 ++ .../Api/Client/Servers/PowerController.php | 2 + .../Wings/DaemonCommandRepository.php | 20 +- .../Wings/DaemonFileRepository.php | 203 ++++++++++++------ .../Wings/DaemonPowerRepository.php | 16 +- 6 files changed, 191 insertions(+), 81 deletions(-) diff --git a/app/Http/Controllers/Api/Client/Servers/CommandController.php b/app/Http/Controllers/Api/Client/Servers/CommandController.php index d4551aa2f..b8390673e 100644 --- a/app/Http/Controllers/Api/Client/Servers/CommandController.php +++ b/app/Http/Controllers/Api/Client/Servers/CommandController.php @@ -5,7 +5,6 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Psr\Http\Message\ResponseInterface; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Exception\BadResponseException; use Symfony\Component\HttpKernel\Exception\HttpException; use Pterodactyl\Repositories\Wings\DaemonCommandRepository; @@ -45,11 +44,13 @@ class CommandController extends ClientApiController { try { $this->repository->setServer($server)->send($request->input('command')); - } catch (RequestException $exception) { - if ($exception instanceof BadResponseException) { + } catch (DaemonConnectionException $exception) { + $previous = $exception->getPrevious(); + + if ($previous instanceof BadResponseException) { if ( - $exception->getResponse() instanceof ResponseInterface - && $exception->getResponse()->getStatusCode() === Response::HTTP_BAD_GATEWAY + $previous->getResponse() instanceof ResponseInterface + && $previous->getResponse()->getStatusCode() === Response::HTTP_BAD_GATEWAY ) { throw new HttpException( Response::HTTP_BAD_GATEWAY, 'Server must be online in order to send commands.', $exception @@ -57,7 +58,7 @@ class CommandController extends ClientApiController } } - throw new DaemonConnectionException($exception); + throw $exception; } return $this->returnNoContent(); diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index 60fc88777..c4e4a035f 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -19,6 +19,7 @@ use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CreateFolderRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CompressFilesRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DecompressFilesRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\WriteFileContentRequest; @@ -88,7 +89,9 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\Response + * * @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function contents(GetFileContentsRequest $request, Server $server): Response { @@ -139,6 +142,8 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\WriteFileContentRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function write(WriteFileContentRequest $request, Server $server): JsonResponse { @@ -156,6 +161,8 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CreateFolderRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function create(CreateFolderRequest $request, Server $server): JsonResponse { @@ -172,6 +179,8 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function rename(RenameFileRequest $request, Server $server): JsonResponse { @@ -188,6 +197,8 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function copy(CopyFileRequest $request, Server $server): JsonResponse { @@ -202,9 +213,14 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CompressFilesRequest $request * @param \Pterodactyl\Models\Server $server * @return array + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function compress(CompressFilesRequest $request, Server $server): array { + // Allow up to five minutes for this request to process before timing out. + set_time_limit(300); + $file = $this->fileRepository->setServer($server) ->compressFiles( $request->input('root'), $request->input('files') @@ -221,6 +237,8 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function delete(DeleteFileRequest $request, Server $server): JsonResponse { diff --git a/app/Http/Controllers/Api/Client/Servers/PowerController.php b/app/Http/Controllers/Api/Client/Servers/PowerController.php index 82cf8b334..12e2d75bb 100644 --- a/app/Http/Controllers/Api/Client/Servers/PowerController.php +++ b/app/Http/Controllers/Api/Client/Servers/PowerController.php @@ -33,6 +33,8 @@ class PowerController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\SendPowerRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\Response + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function index(SendPowerRequest $request, Server $server): Response { diff --git a/app/Repositories/Wings/DaemonCommandRepository.php b/app/Repositories/Wings/DaemonCommandRepository.php index 644bb024c..38f2fb475 100644 --- a/app/Repositories/Wings/DaemonCommandRepository.php +++ b/app/Repositories/Wings/DaemonCommandRepository.php @@ -5,6 +5,8 @@ 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 DaemonCommandRepository extends DaemonRepository { @@ -13,16 +15,22 @@ class DaemonCommandRepository extends DaemonRepository * * @param string|string[] $command * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function send($command): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/commands', $this->server->uuid), - [ - 'json' => ['commands' => is_array($command) ? $command : [$command]], - ] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/commands', $this->server->uuid), + [ + 'json' => ['commands' => is_array($command) ? $command : [$command]], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } } diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index 7a0934b10..3a605ea94 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -5,7 +5,9 @@ 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\Server\FileSizeTooLargeException; +use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; class DaemonFileRepository extends DaemonRepository { @@ -18,17 +20,22 @@ class DaemonFileRepository extends DaemonRepository * * @throws \GuzzleHttp\Exception\TransferException * @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function getContent(string $path, int $notLargerThan = null): string { Assert::isInstanceOf($this->server, Server::class); - $response = $this->getHttpClient()->get( - sprintf('/api/servers/%s/files/contents', $this->server->uuid), - [ - 'query' => ['file' => $path], - ] - ); + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/contents', $this->server->uuid), + [ + 'query' => ['file' => $path], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } $length = (int) $response->getHeader('Content-Length')[0] ?? 0; @@ -47,19 +54,23 @@ class DaemonFileRepository extends DaemonRepository * @param string $content * @return \Psr\Http\Message\ResponseInterface * - * @throws \GuzzleHttp\Exception\TransferException + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function putContent(string $path, string $content): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/files/write', $this->server->uuid), - [ - 'query' => ['file' => $path], - 'body' => $content, - ] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/write', $this->server->uuid), + [ + 'query' => ['file' => $path], + 'body' => $content, + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } /** @@ -68,18 +79,22 @@ class DaemonFileRepository extends DaemonRepository * @param string $path * @return array * - * @throws \GuzzleHttp\Exception\TransferException + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function getDirectory(string $path): array { Assert::isInstanceOf($this->server, Server::class); - $response = $this->getHttpClient()->get( - sprintf('/api/servers/%s/files/list-directory', $this->server->uuid), - [ - 'query' => ['directory' => $path], - ] - ); + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/list-directory', $this->server->uuid), + [ + 'query' => ['directory' => $path], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } return json_decode($response->getBody(), true); } @@ -90,20 +105,26 @@ class DaemonFileRepository extends DaemonRepository * @param string $name * @param string $path * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function createDirectory(string $name, string $path): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/files/create-directory', $this->server->uuid), - [ - 'json' => [ - 'name' => urldecode($name), - 'path' => urldecode($path), - ], - ] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/create-directory', $this->server->uuid), + [ + 'json' => [ + 'name' => urldecode($name), + 'path' => urldecode($path), + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } /** @@ -112,20 +133,26 @@ class DaemonFileRepository extends DaemonRepository * @param string|null $root * @param array $files * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function renameFiles(?string $root, array $files): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->put( - sprintf('/api/servers/%s/files/rename', $this->server->uuid), - [ - 'json' => [ - 'root' => $root ?? '/', - 'files' => $files, - ], - ] - ); + try { + return $this->getHttpClient()->put( + sprintf('/api/servers/%s/files/rename', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } /** @@ -133,19 +160,25 @@ class DaemonFileRepository extends DaemonRepository * * @param string $location * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function copyFile(string $location): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/files/copy', $this->server->uuid), - [ - 'json' => [ - 'location' => urldecode($location), - ], - ] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/copy', $this->server->uuid), + [ + 'json' => [ + 'location' => urldecode($location), + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } /** @@ -154,20 +187,26 @@ class DaemonFileRepository extends DaemonRepository * @param string|null $root * @param array $files * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function deleteFiles(?string $root, array $files): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/files/delete', $this->server->uuid), - [ - 'json' => [ - 'root' => $root ?? '/', - 'files' => $files, - ], - ] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/delete', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } /** @@ -176,21 +215,55 @@ class DaemonFileRepository extends DaemonRepository * @param string|null $root * @param array $files * @return array + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function compressFiles(?string $root, array $files): array { Assert::isInstanceOf($this->server, Server::class); - $response = $this->getHttpClient()->post( - sprintf('/api/servers/%s/files/compress', $this->server->uuid), - [ - 'json' => [ - 'root' => $root ?? '/', - 'files' => $files, - ], - ] - ); + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/compress', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } return json_decode($response->getBody(), true); } + + /** + * Decompresses a given archive file. + * + * @param string|null $root + * @param string $file + * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function decompressFile(?string $root, string $file): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/decompress', $this->server->uuid), + [ + 'json ' => [ + 'root' => $root ?? '/', + 'file' => $file, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } } diff --git a/app/Repositories/Wings/DaemonPowerRepository.php b/app/Repositories/Wings/DaemonPowerRepository.php index d7ef42c4f..ccbf169ff 100644 --- a/app/Repositories/Wings/DaemonPowerRepository.php +++ b/app/Repositories/Wings/DaemonPowerRepository.php @@ -5,6 +5,8 @@ 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 DaemonPowerRepository extends DaemonRepository { @@ -13,14 +15,20 @@ class DaemonPowerRepository extends DaemonRepository * * @param string $action * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function send(string $action): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/power', $this->server->uuid), - ['json' => ['action' => $action]] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/power', $this->server->uuid), + ['json' => ['action' => $action]] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } } From 1a6669aa5c5c73516690b2fa5bc059c81d863d72 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Tue, 14 Jul 2020 21:16:49 -0700 Subject: [PATCH 06/58] Add endpoint support for decompressing files --- .../Api/Client/Servers/FileController.php | 18 +++++++++++ .../Servers/Files/DecompressFilesRequest.php | 32 +++++++++++++++++++ .../api/server/files/decompressFiles.ts | 8 +++++ .../scripts/api/server/files/loadDirectory.ts | 1 + resources/scripts/api/transformers.ts | 8 +++++ .../server/files/FileDropdownMenu.tsx | 26 ++++++++++++--- .../server/files/NewDirectoryButton.tsx | 1 + routes/api-client.php | 1 + 8 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 app/Http/Requests/Api/Client/Servers/Files/DecompressFilesRequest.php create mode 100644 resources/scripts/api/server/files/decompressFiles.ts diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index c4e4a035f..77f672f44 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -231,6 +231,24 @@ class FileController extends ClientApiController ->toArray(); } + /** + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DecompressFilesRequest $request + * @param \Pterodactyl\Models\Server $server + * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function decompress(DecompressFilesRequest $request, Server $server): JsonResponse + { + // Allow up to five minutes for this request to process before timing out. + set_time_limit(300); + + $this->fileRepository->setServer($server) + ->decompressFile($request->input('root'), $request->input('file')); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + /** * Deletes files or folders for the server in the given root directory. * diff --git a/app/Http/Requests/Api/Client/Servers/Files/DecompressFilesRequest.php b/app/Http/Requests/Api/Client/Servers/Files/DecompressFilesRequest.php new file mode 100644 index 000000000..f8493ec4a --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Files/DecompressFilesRequest.php @@ -0,0 +1,32 @@ + 'sometimes|nullable|string', + 'file' => 'required|string', + ]; + } +} diff --git a/resources/scripts/api/server/files/decompressFiles.ts b/resources/scripts/api/server/files/decompressFiles.ts new file mode 100644 index 000000000..d674eadb0 --- /dev/null +++ b/resources/scripts/api/server/files/decompressFiles.ts @@ -0,0 +1,8 @@ +import http from '@/api/http'; + +export default async (uuid: string, directory: string, file: string): Promise => { + await http.post(`/api/client/servers/${uuid}/files/decompress`, { root: directory, file }, { + timeout: 300000, + timeoutErrorMessage: 'It looks like this archive is taking a long time to be unarchived. Once completed the unarchived files will appear.', + }); +}; diff --git a/resources/scripts/api/server/files/loadDirectory.ts b/resources/scripts/api/server/files/loadDirectory.ts index 85f290689..7899d2216 100644 --- a/resources/scripts/api/server/files/loadDirectory.ts +++ b/resources/scripts/api/server/files/loadDirectory.ts @@ -12,6 +12,7 @@ export interface FileObject { mimetype: string; createdAt: Date; modifiedAt: Date; + isArchiveType: () => boolean; } export default async (uuid: string, directory?: string): Promise => { diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index bd59a7e84..c8676bdac 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -23,4 +23,12 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({ mimetype: data.attributes.mimetype, createdAt: new Date(data.attributes.created_at), modifiedAt: new Date(data.attributes.modified_at), + + isArchiveType: function () { + return this.isFile && [ + 'application/zip', + 'application/gzip', + 'application/x-tar', + ].indexOf(this.mimetype) >= 0; + }, }); diff --git a/resources/scripts/components/server/files/FileDropdownMenu.tsx b/resources/scripts/components/server/files/FileDropdownMenu.tsx index e75f8e16b..19fbe522a 100644 --- a/resources/scripts/components/server/files/FileDropdownMenu.tsx +++ b/resources/scripts/components/server/files/FileDropdownMenu.tsx @@ -1,6 +1,7 @@ import React, { useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { + faBoxOpen, faCopy, faEllipsisH, faFileArchive, @@ -27,6 +28,7 @@ import DropdownMenu from '@/components/elements/DropdownMenu'; import styled from 'styled-components/macro'; import useEventListener from '@/plugins/useEventListener'; import compressFiles from '@/api/server/files/compressFiles'; +import decompressFiles from '@/api/server/files/decompressFiles'; type ModalType = 'rename' | 'move'; @@ -43,7 +45,7 @@ interface RowProps extends React.HTMLAttributes { const Row = ({ icon, title, ...props }: RowProps) => ( - + {title} ); @@ -110,6 +112,16 @@ export default ({ file }: { file: FileObject }) => { .then(() => setShowSpinner(false)); }; + const doUnarchive = () => { + setShowSpinner(true); + clearFlashes('files'); + + decompressFiles(uuid, directory, file.name) + .then(() => mutate()) + .catch(error => clearAndAddHttpError({ key: 'files', error })) + .then(() => setShowSpinner(false)); + }; + return ( { } - - - + {file.isArchiveType() ? + + + + : + + + + } diff --git a/resources/scripts/components/server/files/NewDirectoryButton.tsx b/resources/scripts/components/server/files/NewDirectoryButton.tsx index d1f23c022..9adbf57dd 100644 --- a/resources/scripts/components/server/files/NewDirectoryButton.tsx +++ b/resources/scripts/components/server/files/NewDirectoryButton.tsx @@ -34,6 +34,7 @@ const generateDirectoryData = (name: string): FileObject => ({ mimetype: '', createdAt: new Date(), modifiedAt: new Date(), + isArchiveType: () => false, }); export default () => { diff --git a/routes/api-client.php b/routes/api-client.php index 9b57cf0c4..f92ba6ed9 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -60,6 +60,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::post('/copy', 'Servers\FileController@copy'); Route::post('/write', 'Servers\FileController@write'); Route::post('/compress', 'Servers\FileController@compress'); + Route::post('/decompress', 'Servers\FileController@decompress'); Route::post('/delete', 'Servers\FileController@delete'); Route::post('/create-folder', 'Servers\FileController@create'); }); From b6e31096f0b35fc03f752926beb038cdad2b5a1a Mon Sep 17 00:00:00 2001 From: Matthew Penner Date: Wed, 15 Jul 2020 12:29:52 -0600 Subject: [PATCH 07/58] Fix empty request body to wings when decompressing a file --- app/Repositories/Wings/DaemonFileRepository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index 3a605ea94..177f22afd 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -256,7 +256,7 @@ class DaemonFileRepository extends DaemonRepository return $this->getHttpClient()->post( sprintf('/api/servers/%s/files/decompress', $this->server->uuid), [ - 'json ' => [ + 'json' => [ 'root' => $root ?? '/', 'file' => $file, ], From df385cef3ae9335f401cc082256aa089563bb350 Mon Sep 17 00:00:00 2001 From: Matthew Penner Date: Wed, 15 Jul 2020 13:09:45 -0600 Subject: [PATCH 08/58] Add additional mimetypes to check if a file is an archive --- resources/scripts/api/transformers.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index c8676bdac..4548c4b1e 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -26,9 +26,17 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({ isArchiveType: function () { return this.isFile && [ - 'application/zip', - 'application/gzip', - 'application/x-tar', + 'application/vnd.rar', // .rar + 'application/x-rar-compressed', // .rar (2) + 'application/x-tar', // .tar + 'application/x-br', // .tar.br + 'application/x-bzip2', // .tar.bz2, .bz2 + 'application/gzip', // .tar.gz, .gz + 'application/x-lzip', // .tar.lz4, .lz4 (not sure if this mime type is correct) + 'application/x-sz', // .tar.sz, .sz (not sure if this mime type is correct) + 'application/x-xz', // .tar.xz, .xz + 'application/zstd', // .tar.zst, .zst + 'application/zip', // .zip ].indexOf(this.mimetype) >= 0; }, }); From c2b1e7e6abfb1668a1ff5d7cd9c76d317c44b0a2 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Thu, 16 Jul 2020 22:21:06 -0700 Subject: [PATCH 09/58] Use archive icon --- resources/scripts/components/server/files/FileObjectRow.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/scripts/components/server/files/FileObjectRow.tsx b/resources/scripts/components/server/files/FileObjectRow.tsx index 934fd6320..9baf5b536 100644 --- a/resources/scripts/components/server/files/FileObjectRow.tsx +++ b/resources/scripts/components/server/files/FileObjectRow.tsx @@ -1,5 +1,5 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faFileAlt, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons'; +import { faFileAlt, faFileArchive, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons'; import { bytesToHuman, cleanDirectoryPath } from '@/helpers'; import { differenceInHours, format, formatDistanceToNow } from 'date-fns'; import React, { memo } from 'react'; @@ -53,7 +53,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => { >
{file.isFile ? - + : } From d64475898681f11b3165552799743e80d8b1aa1c Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 18 Jul 2020 10:23:28 -0700 Subject: [PATCH 10/58] Always return the status code from the daemon if possible --- .../Http/Connection/DaemonConnectionException.php | 2 +- .../Api/Client/Servers/FileController.php | 12 +++--------- app/Repositories/Wings/DaemonServerRepository.php | 2 +- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/app/Exceptions/Http/Connection/DaemonConnectionException.php b/app/Exceptions/Http/Connection/DaemonConnectionException.php index f2f8ba13d..2eb7e93ca 100644 --- a/app/Exceptions/Http/Connection/DaemonConnectionException.php +++ b/app/Exceptions/Http/Connection/DaemonConnectionException.php @@ -22,7 +22,7 @@ class DaemonConnectionException extends DisplayException * @param \GuzzleHttp\Exception\GuzzleException $previous * @param bool $useStatusCode */ - public function __construct(GuzzleException $previous, bool $useStatusCode = false) + public function __construct(GuzzleException $previous, bool $useStatusCode = true) { /** @var \GuzzleHttp\Psr7\Response|null $response */ $response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null; diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index 77f672f44..1f18259ae 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -6,13 +6,11 @@ use Carbon\CarbonImmutable; use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; -use GuzzleHttp\Exception\TransferException; use Pterodactyl\Services\Nodes\NodeJWTService; use Illuminate\Contracts\Routing\ResponseFactory; use Pterodactyl\Repositories\Wings\DaemonFileRepository; use Pterodactyl\Transformers\Daemon\FileObjectTransformer; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; -use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\ListFilesRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest; @@ -70,13 +68,9 @@ class FileController extends ClientApiController */ public function directory(ListFilesRequest $request, Server $server): array { - try { - $contents = $this->fileRepository - ->setServer($server) - ->getDirectory($request->get('directory') ?? '/'); - } catch (TransferException $exception) { - throw new DaemonConnectionException($exception, true); - } + $contents = $this->fileRepository + ->setServer($server) + ->getDirectory($request->get('directory') ?? '/'); return $this->fractal->collection($contents) ->transformWith($this->getTransformer(FileObjectTransformer::class)) diff --git a/app/Repositories/Wings/DaemonServerRepository.php b/app/Repositories/Wings/DaemonServerRepository.php index b41c75483..abb5dae4e 100644 --- a/app/Repositories/Wings/DaemonServerRepository.php +++ b/app/Repositories/Wings/DaemonServerRepository.php @@ -23,7 +23,7 @@ class DaemonServerRepository extends DaemonRepository sprintf('/api/servers/%s', $this->server->uuid) ); } catch (TransferException $exception) { - throw new DaemonConnectionException($exception); + throw new DaemonConnectionException($exception, false); } return json_decode($response->getBody()->__toString(), true); From ff9f893dc3c72e7a88a55db96835d07d9cbf69be Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 18 Jul 2020 10:45:41 -0700 Subject: [PATCH 11/58] Code cleanup for file manager --- .../components/server/files/FileManagerContainer.tsx | 4 ---- .../components/server/files/FileObjectRow.tsx | 3 --- resources/scripts/plugins/useFileManagerSwr.ts | 12 ++++++------ 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index 520365104..def0944c2 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -30,10 +30,6 @@ export default () => { const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles); useEffect(() => { - // We won't automatically mutate the store when the component re-mounts, otherwise because of - // my (horrible) programming this fires off way more than we intend it to. - mutate(); - setSelectedFiles([]); setDirectory(hash.length > 0 ? hash : '/'); }, [ hash ]); diff --git a/resources/scripts/components/server/files/FileObjectRow.tsx b/resources/scripts/components/server/files/FileObjectRow.tsx index 9baf5b536..a78a83cc1 100644 --- a/resources/scripts/components/server/files/FileObjectRow.tsx +++ b/resources/scripts/components/server/files/FileObjectRow.tsx @@ -18,7 +18,6 @@ const Row = styled.div` const FileObjectRow = ({ file }: { file: FileObject }) => { const directory = ServerContext.useStoreState(state => state.files.directory); - const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); const history = useHistory(); const match = useRouteMatch(); @@ -31,9 +30,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => { // Just trust me future me, leave this be. if (!file.isFile) { e.preventDefault(); - history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`); - setDirectory(`${directory}/${file.name}`); } }; diff --git a/resources/scripts/plugins/useFileManagerSwr.ts b/resources/scripts/plugins/useFileManagerSwr.ts index 50b69478a..16721e72c 100644 --- a/resources/scripts/plugins/useFileManagerSwr.ts +++ b/resources/scripts/plugins/useFileManagerSwr.ts @@ -2,18 +2,18 @@ import useSWR from 'swr'; import loadDirectory, { FileObject } from '@/api/server/files/loadDirectory'; import { cleanDirectoryPath } from '@/helpers'; import useServer from '@/plugins/useServer'; -import { useLocation } from 'react-router'; +import { ServerContext } from '@/state/server'; export default () => { const { uuid } = useServer(); - const { hash } = useLocation(); + const directory = ServerContext.useStoreState(state => state.files.directory); return useSWR( - `${uuid}:files:${hash}`, - () => loadDirectory(uuid, cleanDirectoryPath(hash)), + `${uuid}:files:${directory}`, + () => loadDirectory(uuid, cleanDirectoryPath(directory)), { - revalidateOnMount: false, + revalidateOnMount: true, refreshInterval: 0, - } + }, ); }; From 24db6d9128513ab372c11a26f28a8a5142e5fa05 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 19 Jul 2020 14:35:47 -0700 Subject: [PATCH 12/58] Update FUNDING.yml --- .github/FUNDING.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 785de6c73..985087d16 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ -#github: [DaneEveritt] +github: [DaneEveritt] custom: ["https://paypal.me/PterodactylSoftware"] From 1fe254efc6cbaa8713ae1bdf7ca495c7eb5db9ef Mon Sep 17 00:00:00 2001 From: Charles Morgan Date: Wed, 22 Jul 2020 01:54:49 -0400 Subject: [PATCH 13/58] Re-add scroll bar style, fix missed tw conversion Fixed backup message still using old method of "className" changed to use css={ts} readded scrollbar styling from PR#2118 --- .../scripts/assets/css/GlobalStylesheet.ts | 45 +++++++++++++++++-- .../server/backups/BackupContainer.tsx | 2 +- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/resources/scripts/assets/css/GlobalStylesheet.ts b/resources/scripts/assets/css/GlobalStylesheet.ts index 5cc44cea6..a38dff74e 100644 --- a/resources/scripts/assets/css/GlobalStylesheet.ts +++ b/resources/scripts/assets/css/GlobalStylesheet.ts @@ -6,19 +6,19 @@ export default createGlobalStyle` ${tw`font-sans bg-neutral-800 text-neutral-200`}; letter-spacing: 0.015em; } - + h1, h2, h3, h4, h5, h6 { ${tw`font-medium tracking-normal font-header`}; } - + p { ${tw`text-neutral-200 leading-snug font-sans`}; } - + form { ${tw`m-0`}; } - + textarea, select, input, button, button:focus, button:focus-visible { ${tw`outline-none`}; } @@ -32,4 +32,41 @@ export default createGlobalStyle` input[type=number] { -moz-appearance: textfield !important; } + + /* Scroll Bar Style */ + ::-webkit-scrollbar { + background: none; + width: 16px; + height: 16px; + } + + ::-webkit-scrollbar-thumb { + border: solid 0 rgb(0 0 0 / 0%); + border-right-width: 4px; + border-left-width: 4px; + -webkit-border-radius: 9px 4px; + -webkit-box-shadow: inset 0 0 0 1px hsl(211, 10%, 53%), inset 0 0 0 4px hsl(209deg 18% 30%); + } + + ::-webkit-scrollbar-track-piece { + margin: 4px 0; + } + + ::-webkit-scrollbar-thumb:horizontal { + border-right-width: 0; + border-left-width: 0; + border-top-width: 4px; + border-bottom-width: 4px; + -webkit-border-radius: 4px 9px; + } + + ::-webkit-scrollbar-thumb:hover { + -webkit-box-shadow: + inset 0 0 0 1px hsl(212, 92%, 43%), + inset 0 0 0 4px hsl(212, 92%, 43%); + } + + ::-webkit-scrollbar-corner { + background: transparent; + } `; diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index 669f04e84..feb4e5f2e 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -52,7 +52,7 @@ export default () => {
} {featureLimits.backups === 0 && -

+

Backups cannot be created for this server.

} From f0ac0725b615204bb52bdfc48bca339404416112 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 26 Jul 2020 10:43:46 -0700 Subject: [PATCH 14/58] [Security] Don't return all servers on the system when not a root admin and admin level servers are requested Cleaned up the API endpoint by simplifying the logic and adds test case to cover this bug. If you ever need to list _all_ of the servers on the system you should be using the application API endpoint for the servers most likely. --- .../Api/Client/ClientController.php | 35 ++++---- .../Requests/Api/Client/GetServersRequest.php | 26 ------ app/Models/User.php | 5 -- resources/scripts/api/getServers.ts | 6 +- .../dashboard/DashboardContainer.tsx | 18 ++-- .../Api/Client/ClientControllerTest.php | 82 +++++++++++++------ 6 files changed, 84 insertions(+), 88 deletions(-) diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 300770aa5..5eec40b51 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -2,7 +2,6 @@ namespace Pterodactyl\Http\Controllers\Api\Client; -use Pterodactyl\Models\User; use Pterodactyl\Models\Server; use Pterodactyl\Models\Permission; use Spatie\QueryBuilder\QueryBuilder; @@ -39,31 +38,27 @@ class ClientController extends ClientApiController public function index(GetServersRequest $request): array { $user = $request->user(); - $level = $request->getFilterLevel(); $transformer = $this->getTransformer(ServerTransformer::class); // Start the query builder and ensure we eager load any requested relationships from the request. - $builder = Server::query()->with($this->getIncludesForTransformer($transformer, ['node'])); + $builder = QueryBuilder::for( + Server::query()->with($this->getIncludesForTransformer($transformer, ['node'])) + )->allowedFilters('uuid', 'name', 'external_id'); - if ($level === User::FILTER_LEVEL_OWNER) { - $builder = $builder->where('owner_id', $request->user()->id); - } - // If set to all, display all servers they can access, including those they access as an - // admin. If set to subuser, only return the servers they can access because they are owner, - // or marked as a subuser of the server. - elseif (($level === User::FILTER_LEVEL_ALL && ! $user->root_admin) || $level === User::FILTER_LEVEL_SUBUSER) { + // Either return all of the servers the user has access to because they are an admin `?type=admin` or + // just return all of the servers the user has access to because they are the owner or a subuser of the + // server. + if ($request->input('type') === 'admin') { + $builder = $user->root_admin + ? $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all()) + // If they aren't an admin but want all the admin servers don't fail the request, just + // make it a query that will never return any results back. + : $builder->whereRaw('1 = 2'); + } elseif ($request->input('type') === 'owner') { + $builder = $builder->where('owner_id', $user->id); + } else { $builder = $builder->whereIn('id', $user->accessibleServers()->pluck('id')->all()); } - // If set to admin, only display the servers a user can access because they are an administrator. - // This means only servers the user would not have access to if they were not an admin (because they - // are not an owner or subuser) are returned. - elseif ($level === User::FILTER_LEVEL_ADMIN && $user->root_admin) { - $builder = $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all()); - } - - $builder = QueryBuilder::for($builder)->allowedFilters( - 'uuid', 'name', 'external_id' - ); $servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query()); diff --git a/app/Http/Requests/Api/Client/GetServersRequest.php b/app/Http/Requests/Api/Client/GetServersRequest.php index c28f0a946..9b4601f25 100644 --- a/app/Http/Requests/Api/Client/GetServersRequest.php +++ b/app/Http/Requests/Api/Client/GetServersRequest.php @@ -2,8 +2,6 @@ namespace Pterodactyl\Http\Requests\Api\Client; -use Pterodactyl\Models\User; - class GetServersRequest extends ClientApiRequest { /** @@ -13,28 +11,4 @@ class GetServersRequest extends ClientApiRequest { return true; } - - /** - * Return the filtering method for servers when the client base endpoint is requested. - * - * @return int - */ - public function getFilterLevel(): int - { - switch ($this->input('type')) { - case 'all': - return User::FILTER_LEVEL_ALL; - break; - case 'admin': - return User::FILTER_LEVEL_ADMIN; - break; - case 'owner': - return User::FILTER_LEVEL_OWNER; - break; - case 'subuser-of': - default: - return User::FILTER_LEVEL_SUBUSER; - break; - } - } } diff --git a/app/Models/User.php b/app/Models/User.php index baff65b6f..39954fbf3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -57,11 +57,6 @@ class User extends Model implements const USER_LEVEL_USER = 0; const USER_LEVEL_ADMIN = 1; - const FILTER_LEVEL_ALL = 0; - const FILTER_LEVEL_OWNER = 1; - const FILTER_LEVEL_ADMIN = 2; - const FILTER_LEVEL_SUBUSER = 3; - /** * The resource name for this model when it is transformed into an * API representation using fractal. diff --git a/resources/scripts/api/getServers.ts b/resources/scripts/api/getServers.ts index 492898f70..63329bfa7 100644 --- a/resources/scripts/api/getServers.ts +++ b/resources/scripts/api/getServers.ts @@ -4,14 +4,14 @@ import http, { getPaginationSet, PaginatedResult } from '@/api/http'; interface QueryParams { query?: string; page?: number; - includeAdmin?: boolean; + onlyAdmin?: boolean; } -export default ({ query, page = 1, includeAdmin = false }: QueryParams): Promise> => { +export default ({ query, page = 1, onlyAdmin = false }: QueryParams): Promise> => { return new Promise((resolve, reject) => { http.get('/api/client', { params: { - type: includeAdmin ? 'all' : undefined, + type: onlyAdmin ? 'admin' : undefined, 'filter[name]': query, page, }, diff --git a/resources/scripts/components/dashboard/DashboardContainer.tsx b/resources/scripts/components/dashboard/DashboardContainer.tsx index 60c18cb12..a20472604 100644 --- a/resources/scripts/components/dashboard/DashboardContainer.tsx +++ b/resources/scripts/components/dashboard/DashboardContainer.tsx @@ -17,11 +17,11 @@ export default () => { const { clearFlashes, clearAndAddHttpError } = useFlash(); const [ page, setPage ] = useState(1); const { rootAdmin } = useStoreState(state => state.user.data!); - const [ includeAdmin, setIncludeAdmin ] = usePersistedState('show_all_servers', false); + const [ showOnlyAdmin, setShowOnlyAdmin ] = usePersistedState('show_all_servers', false); const { data: servers, error } = useSWR>( - [ '/api/client/servers', includeAdmin, page ], - () => getServers({ includeAdmin, page }), + [ '/api/client/servers', showOnlyAdmin, page ], + () => getServers({ onlyAdmin: showOnlyAdmin, page }), ); useEffect(() => { @@ -34,12 +34,12 @@ export default () => { {rootAdmin &&

- {includeAdmin ? 'Showing all servers' : 'Showing your servers'} + {showOnlyAdmin ? 'Showing other\'s servers' : 'Showing your servers'}

setIncludeAdmin(s => !s)} + defaultChecked={showOnlyAdmin} + onChange={() => setShowOnlyAdmin(s => !s)} />
} @@ -58,7 +58,11 @@ export default () => { )) :

- There are no servers associated with your account. + {showOnlyAdmin ? + 'There are no other servers to display.' + : + 'There are no servers associated with your account.' + }

)} diff --git a/tests/Integration/Api/Client/ClientControllerTest.php b/tests/Integration/Api/Client/ClientControllerTest.php index 82aeb564d..b894b14b0 100644 --- a/tests/Integration/Api/Client/ClientControllerTest.php +++ b/tests/Integration/Api/Client/ClientControllerTest.php @@ -38,33 +38,6 @@ class ClientControllerTest extends ClientApiIntegrationTestCase $response->assertJsonPath('meta.pagination.per_page', 50); } - /** - * Tests that all of the servers on the system are returned when making the request as an - * administrator and including the ?filter=all parameter in the URL. - */ - public function testFilterIncludeAllServersWhenAdministrator() - { - /** @var \Pterodactyl\Models\User[] $users */ - $users = factory(User::class)->times(3)->create(); - $users[0]->root_admin = true; - - $servers = [ - $this->createServerModel(['user_id' => $users[0]->id]), - $this->createServerModel(['user_id' => $users[1]->id]), - $this->createServerModel(['user_id' => $users[2]->id]), - ]; - - $response = $this->actingAs($users[0])->getJson('/api/client?type=all'); - - $response->assertOk(); - $response->assertJsonCount(3, 'data'); - - for ($i = 0; $i < 3; $i++) { - $response->assertJsonPath("data.{$i}.attributes.server_owner", $i === 0); - $response->assertJsonPath("data.{$i}.attributes.identifier", $servers[$i]->uuidShort); - } - } - /** * Test that servers where the user is a subuser are returned by default in the API call. */ @@ -143,4 +116,59 @@ class ClientControllerTest extends ClientApiIntegrationTestCase ], ]); } + + /** + * Test that only servers a user can access because they are an administrator are returned. This + * will always exclude any servers they can see because they're the owner or a subuser of the server. + */ + public function testOnlyAdminLevelServersAreReturned() + { + /** @var \Pterodactyl\Models\User[] $users */ + $users = factory(User::class)->times(4)->create(); + $users[0]->update(['root_admin' => true]); + + $servers = [ + $this->createServerModel(['user_id' => $users[0]->id]), + $this->createServerModel(['user_id' => $users[1]->id]), + $this->createServerModel(['user_id' => $users[2]->id]), + $this->createServerModel(['user_id' => $users[3]->id]), + ]; + + Subuser::query()->create([ + 'user_id' => $users[0]->id, + 'server_id' => $servers[1]->id, + 'permissions' => [Permission::ACTION_WEBSOCKET_CONNECT], + ]); + + // Only servers 2 & 3 (0 indexed) should be returned by the API at this point. The user making + // the request is the owner of server 0, and a subuser of server 1 so they should be exluded. + $response = $this->actingAs($users[0])->getJson('/api/client?type=admin'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + + $response->assertJsonPath('data.0.attributes.server_owner', false); + $response->assertJsonPath('data.0.attributes.identifier', $servers[2]->uuidShort); + $response->assertJsonPath('data.1.attributes.server_owner', false); + $response->assertJsonPath('data.1.attributes.identifier', $servers[3]->uuidShort); + } + + /** + * Test that no servers get returned if the user requests all admin level servers by using + * ?type=admin in the request. + */ + public function testNoServersAreReturnedIfAdminFilterIsPassedByRegularUser() + { + /** @var \Pterodactyl\Models\User[] $users */ + $users = factory(User::class)->times(3)->create(); + + $this->createServerModel(['user_id' => $users[0]->id]); + $this->createServerModel(['user_id' => $users[1]->id]); + $this->createServerModel(['user_id' => $users[2]->id]); + + $response = $this->actingAs($users[0])->getJson('/api/client?type=admin'); + + $response->assertOk(); + $response->assertJsonCount(0, 'data'); + } } From 4a27e56e08b1f9734964652a3536aba6f43695a8 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sun, 26 Jul 2020 10:55:30 -0700 Subject: [PATCH 15/58] Fix test --- .../Integration/Api/Client/Server/CommandControllerTest.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Api/Client/Server/CommandControllerTest.php b/tests/Integration/Api/Client/Server/CommandControllerTest.php index 3d7cd090f..de3dacc85 100644 --- a/tests/Integration/Api/Client/Server/CommandControllerTest.php +++ b/tests/Integration/Api/Client/Server/CommandControllerTest.php @@ -9,6 +9,7 @@ use Pterodactyl\Models\Permission; use GuzzleHttp\Exception\BadResponseException; use GuzzleHttp\Psr7\Response as GuzzleResponse; use Pterodactyl\Repositories\Wings\DaemonCommandRepository; +use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase; class CommandControllerTest extends ClientApiIntegrationTestCase @@ -86,7 +87,9 @@ class CommandControllerTest extends ClientApiIntegrationTestCase [$user, $server] = $this->generateTestAccount(); $this->repository->expects('setServer->send')->andThrows( - new BadResponseException('', new Request('GET', 'test'), new GuzzleResponse(Response::HTTP_BAD_GATEWAY)) + new DaemonConnectionException( + new BadResponseException('', new Request('GET', 'test'), new GuzzleResponse(Response::HTTP_BAD_GATEWAY)) + ) ); $response = $this->actingAs($user)->postJson("/api/client/servers/{$server->uuid}/command", [ From cb4f8efbe673bca52d926b3a9e379a6cda9fabf5 Mon Sep 17 00:00:00 2001 From: Charles Morgan Date: Sun, 26 Jul 2020 21:05:54 -0400 Subject: [PATCH 16/58] Add Google Analytics Added Google Analytics to latest dev branch --- .../Settings/BaseSettingsFormRequest.php | 2 + app/Http/ViewComposers/AssetComposer.php | 1 + app/Providers/SettingsServiceProvider.php | 1 + package.json | 1 + resources/scripts/components/App.tsx | 8 ++- .../scripts/routers/AuthenticationRouter.tsx | 37 ++++++++------ resources/scripts/routers/DashboardRouter.tsx | 51 +++++++++++-------- resources/scripts/routers/ServerRouter.tsx | 5 ++ resources/scripts/state/settings.ts | 1 + .../views/admin/settings/index.blade.php | 7 +++ yarn.lock | 5 ++ 11 files changed, 81 insertions(+), 38 deletions(-) diff --git a/app/Http/Requests/Admin/Settings/BaseSettingsFormRequest.php b/app/Http/Requests/Admin/Settings/BaseSettingsFormRequest.php index 0b02561dd..777761b67 100644 --- a/app/Http/Requests/Admin/Settings/BaseSettingsFormRequest.php +++ b/app/Http/Requests/Admin/Settings/BaseSettingsFormRequest.php @@ -19,6 +19,7 @@ class BaseSettingsFormRequest extends AdminFormRequest 'app:name' => 'required|string|max:255', 'pterodactyl:auth:2fa_required' => 'required|integer|in:0,1,2', 'app:locale' => ['required', 'string', Rule::in(array_keys($this->getAvailableLanguages()))], + 'app:analytics' => 'nullable|string', ]; } @@ -31,6 +32,7 @@ class BaseSettingsFormRequest extends AdminFormRequest 'app:name' => 'Company Name', 'pterodactyl:auth:2fa_required' => 'Require 2-Factor Authentication', 'app:locale' => 'Default Language', + 'app:analytics' => 'Google Analytics', ]; } } diff --git a/app/Http/ViewComposers/AssetComposer.php b/app/Http/ViewComposers/AssetComposer.php index 7e8f82dbc..6da825ad4 100644 --- a/app/Http/ViewComposers/AssetComposer.php +++ b/app/Http/ViewComposers/AssetComposer.php @@ -37,6 +37,7 @@ class AssetComposer 'enabled' => config('recaptcha.enabled', false), 'siteKey' => config('recaptcha.website_key') ?? '', ], + 'analytics' => config('app.analytics') ?? '', ]); } } diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 8a1d4db21..abd88c04b 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -21,6 +21,7 @@ class SettingsServiceProvider extends ServiceProvider protected $keys = [ 'app:name', 'app:locale', + 'app:analytics', 'recaptcha:enabled', 'recaptcha:secret_key', 'recaptcha:website_key', diff --git a/package.json b/package.json index 99bcf0d37..3a81f98fa 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "path": "^0.12.7", "query-string": "^6.7.0", "react": "^16.13.1", + "react-ga": "^3.1.2", "react-dom": "npm:@hot-loader/react-dom", "react-fast-compare": "^3.2.0", "react-google-recaptcha": "^2.0.1", diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index dac7fd102..350387fac 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -1,4 +1,5 @@ -import * as React from 'react'; +import React, { useEffect } from 'react'; +import ReactGA from 'react-ga'; import { hot } from 'react-hot-loader/root'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; import { StoreProvider } from 'easy-peasy'; @@ -48,6 +49,11 @@ const App = () => { store.getActions().settings.setSettings(SiteConfiguration!); } + useEffect(() => { + ReactGA.initialize(SiteConfiguration!.analytics); + ReactGA.pageview(location.pathname); + }, []); + return ( <> diff --git a/resources/scripts/routers/AuthenticationRouter.tsx b/resources/scripts/routers/AuthenticationRouter.tsx index a7c687eef..57d1422ca 100644 --- a/resources/scripts/routers/AuthenticationRouter.tsx +++ b/resources/scripts/routers/AuthenticationRouter.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import ReactGA from 'react-ga'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; import LoginContainer from '@/components/auth/LoginContainer'; import ForgotPasswordContainer from '@/components/auth/ForgotPasswordContainer'; @@ -6,17 +7,23 @@ import ResetPasswordContainer from '@/components/auth/ResetPasswordContainer'; import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer'; import NotFound from '@/components/screens/NotFound'; -export default ({ location, history, match }: RouteComponentProps) => ( -
- - - - - - - - history.push('/auth/login')}/> - - -
-); +export default ({ location, history, match }: RouteComponentProps) => { + useEffect(() => { + ReactGA.pageview(location.pathname); + }, [ location.pathname ]); + + return ( +
+ + + + + + + + history.push('/auth/login')} /> + + +
+ ); +}; diff --git a/resources/scripts/routers/DashboardRouter.tsx b/resources/scripts/routers/DashboardRouter.tsx index 79ebbe4a1..7a895a7e4 100644 --- a/resources/scripts/routers/DashboardRouter.tsx +++ b/resources/scripts/routers/DashboardRouter.tsx @@ -1,4 +1,5 @@ -import * as React from 'react'; +import React, { useEffect } from 'react'; +import ReactGA from 'react-ga'; import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom'; import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer'; import NavigationBar from '@/components/NavigationBar'; @@ -8,24 +9,30 @@ import NotFound from '@/components/screens/NotFound'; import TransitionRouter from '@/TransitionRouter'; import SubNavigation from '@/components/elements/SubNavigation'; -export default ({ location }: RouteComponentProps) => ( - <> - - {location.pathname.startsWith('/account') && - -
- Settings - API Credentials -
-
- } - - - - - - - - - -); +export default ({ location }: RouteComponentProps) => { + useEffect(() => { + ReactGA.pageview(location.pathname); + }, [ location.pathname ]); + + return ( + <> + + {location.pathname.startsWith('/account') && + +
+ Settings + API Credentials +
+
+ } + + + + + + + + + + ); +}; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 9df270eaa..2e9ee9ed3 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import ReactGA from 'react-ga'; import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom'; import NavigationBar from '@/components/NavigationBar'; import ServerConsole from '@/components/server/ServerConsole'; @@ -60,6 +61,10 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) }; }, [ match.params.id ]); + useEffect(() => { + ReactGA.pageview(location.pathname); + }, [ location.pathname ]); + return ( diff --git a/resources/scripts/state/settings.ts b/resources/scripts/state/settings.ts index 20dbbdc6e..3eb782d91 100644 --- a/resources/scripts/state/settings.ts +++ b/resources/scripts/state/settings.ts @@ -7,6 +7,7 @@ export interface SiteSettings { enabled: boolean; siteKey: string; }; + analytics: string; } export interface SettingsStore { diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php index 489646dc9..5ccec0dfa 100644 --- a/resources/views/admin/settings/index.blade.php +++ b/resources/views/admin/settings/index.blade.php @@ -31,6 +31,13 @@

This is the name that is used throughout the panel and in emails sent to clients.

+
+ +
+ +

This is your Google Analytics Tracking ID, Ex. UA-123723645-2

+
+
diff --git a/yarn.lock b/yarn.lock index 62c1da6fb..f20fef049 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5569,6 +5569,11 @@ react-fast-compare@^3.2.0: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== +react-ga@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-3.1.2.tgz#e13f211c51a2e5c401ea69cf094b9501fe3c51ce" + integrity sha512-OJrMqaHEHbodm+XsnjA6ISBEHTwvpFrxco65mctzl/v3CASMSLSyUkFqz9yYrPDKGBUfNQzKCjuMJwctjlWBbw== + react-google-recaptcha@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-2.0.1.tgz#3276b29659493f7ca2a5b7739f6c239293cdf1d8" From 6d79ad23a5f50c4853f8e305bb123295177b0ed5 Mon Sep 17 00:00:00 2001 From: Charles Morgan Date: Sun, 26 Jul 2020 23:32:24 -0400 Subject: [PATCH 17/58] Attempt 2? 80% sure this isn't how to use react-helmet.... but it works.... --- package.json | 2 ++ .../scripts/components/server/ServerConsole.tsx | 4 ++++ .../server/backups/BackupContainer.tsx | 5 +++++ .../server/databases/DatabasesContainer.tsx | 5 +++++ .../server/files/FileManagerContainer.tsx | 6 ++++++ .../server/network/NetworkContainer.tsx | 7 +++++++ .../server/schedules/ScheduleContainer.tsx | 5 +++++ .../server/settings/SettingsContainer.tsx | 4 ++++ .../components/server/users/UsersContainer.tsx | 5 +++++ yarn.lock | 17 ++++++++++++++++- 10 files changed, 59 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 99bcf0d37..52d866a06 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react-dom": "npm:@hot-loader/react-dom", "react-fast-compare": "^3.2.0", "react-google-recaptcha": "^2.0.1", + "react-helmet": "^6.1.0", "react-hot-loader": "^4.12.21", "react-i18next": "^11.2.1", "react-redux": "^7.1.0", @@ -61,6 +62,7 @@ "@types/query-string": "^6.3.0", "@types/react": "^16.9.41", "@types/react-dom": "^16.9.8", + "@types/react-helmet": "^6.0.0", "@types/react-redux": "^7.1.1", "@types/react-router": "^5.1.3", "@types/react-router-dom": "^5.1.3", diff --git a/resources/scripts/components/server/ServerConsole.tsx b/resources/scripts/components/server/ServerConsole.tsx index e90c86035..74ba4d750 100644 --- a/resources/scripts/components/server/ServerConsole.tsx +++ b/resources/scripts/components/server/ServerConsole.tsx @@ -1,4 +1,5 @@ import React, { lazy, useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import { ServerContext } from '@/state/server'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons'; @@ -61,6 +62,9 @@ export default () => { return ( + + {server.name} | Console +

diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index 669f04e84..d75fb2a7d 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import Spinner from '@/components/elements/Spinner'; import getServerBackups from '@/api/server/backups/getServerBackups'; import useServer from '@/plugins/useServer'; @@ -18,6 +19,7 @@ export default () => { const [ loading, setLoading ] = useState(true); const backups = ServerContext.useStoreState(state => state.backups.data); + const server = ServerContext.useStoreState(state => state.server.data!); const setBackups = ServerContext.useStoreActions(actions => actions.backups.setBackups); useEffect(() => { @@ -37,6 +39,9 @@ export default () => { return ( + + {server.name} | Backups + {!backups.length ?

diff --git a/resources/scripts/components/server/databases/DatabasesContainer.tsx b/resources/scripts/components/server/databases/DatabasesContainer.tsx index 486072598..462d90fb1 100644 --- a/resources/scripts/components/server/databases/DatabasesContainer.tsx +++ b/resources/scripts/components/server/databases/DatabasesContainer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import getServerDatabases from '@/api/server/getServerDatabases'; import { ServerContext } from '@/state/server'; import { httpErrorToHuman } from '@/api/http'; @@ -19,6 +20,7 @@ export default () => { const [ loading, setLoading ] = useState(true); const databases = ServerContext.useStoreState(state => state.databases.data); + const servername = ServerContext.useStoreState(state => state.server.data.name); const setDatabases = ServerContext.useStoreActions(state => state.databases.setDatabases); useEffect(() => { @@ -36,6 +38,9 @@ export default () => { return ( + + {servername} | Databases + {(!databases.length && loading) ? diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index def0944c2..9a1b68912 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -1,4 +1,5 @@ import React, { useEffect } from 'react'; +import { Helmet } from 'react-helmet'; import { httpErrorToHuman } from '@/api/http'; import { CSSTransition } from 'react-transition-group'; import Spinner from '@/components/elements/Spinner'; @@ -26,6 +27,8 @@ export default () => { const { id } = useServer(); const { hash } = useLocation(); const { data: files, error, mutate } = useFileManagerSwr(); + + const servername = ServerContext.useStoreState(state => state.server.data.name); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles); @@ -42,6 +45,9 @@ export default () => { return ( + + {servername} | File Manager + { !files ? diff --git a/resources/scripts/components/server/network/NetworkContainer.tsx b/resources/scripts/components/server/network/NetworkContainer.tsx index 1723f9352..4470f681e 100644 --- a/resources/scripts/components/server/network/NetworkContainer.tsx +++ b/resources/scripts/components/server/network/NetworkContainer.tsx @@ -1,4 +1,6 @@ import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; +import { ServerContext } from '@/state/server'; import tw from 'twin.macro'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; @@ -28,6 +30,8 @@ const NetworkContainer = () => { const [ loading, setLoading ] = useState(false); const { data, error, mutate } = useSWR(uuid, key => getServerAllocations(key), { initialData: allocations }); + const servername = ServerContext.useStoreState(state => state.server.data.name); + const setPrimaryAllocation = (id: number) => { clearFlashes('server:network'); @@ -61,6 +65,9 @@ const NetworkContainer = () => { return ( + + {servername} | Network + {!data ? : diff --git a/resources/scripts/components/server/schedules/ScheduleContainer.tsx b/resources/scripts/components/server/schedules/ScheduleContainer.tsx index 0e4ff6bd2..2ee4038cc 100644 --- a/resources/scripts/components/server/schedules/ScheduleContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleContainer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import getServerSchedules from '@/api/server/schedules/getServerSchedules'; import { ServerContext } from '@/state/server'; import Spinner from '@/components/elements/Spinner'; @@ -22,6 +23,7 @@ export default ({ match, history }: RouteComponentProps) => { const [ visible, setVisible ] = useState(false); const schedules = ServerContext.useStoreState(state => state.schedules.data); + const servername = ServerContext.useStoreState(state => state.server.data.name); const setSchedules = ServerContext.useStoreActions(actions => actions.schedules.setSchedules); useEffect(() => { @@ -37,6 +39,9 @@ export default ({ match, history }: RouteComponentProps) => { return ( + + {servername} | Schedules + {(!schedules.length && loading) ? diff --git a/resources/scripts/components/server/settings/SettingsContainer.tsx b/resources/scripts/components/server/settings/SettingsContainer.tsx index e060fedf8..edaa3503f 100644 --- a/resources/scripts/components/server/settings/SettingsContainer.tsx +++ b/resources/scripts/components/server/settings/SettingsContainer.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Helmet } from 'react-helmet'; import TitledGreyBox from '@/components/elements/TitledGreyBox'; import { ServerContext } from '@/state/server'; import { useStoreState } from 'easy-peasy'; @@ -20,6 +21,9 @@ export default () => { return ( + + {server.name} | Settings +

diff --git a/resources/scripts/components/server/users/UsersContainer.tsx b/resources/scripts/components/server/users/UsersContainer.tsx index 55f60b449..0925e87df 100644 --- a/resources/scripts/components/server/users/UsersContainer.tsx +++ b/resources/scripts/components/server/users/UsersContainer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import { ServerContext } from '@/state/server'; import { Actions, useStoreActions, useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; @@ -17,6 +18,7 @@ export default () => { const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const subusers = ServerContext.useStoreState(state => state.subusers.data); + const servername = ServerContext.useStoreState(state => state.server.data.name); const setSubusers = ServerContext.useStoreActions(actions => actions.subusers.setSubusers); const permissions = useStoreState((state: ApplicationStore) => state.permissions.data); @@ -49,6 +51,9 @@ export default () => { return ( + + {servername} | Subusers + {!subusers.length ?

diff --git a/yarn.lock b/yarn.lock index 62c1da6fb..253b6cd86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5564,7 +5564,7 @@ react-fast-compare@^2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" -react-fast-compare@^3.2.0: +react-fast-compare@^3.1.1, react-fast-compare@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== @@ -5576,6 +5576,16 @@ react-google-recaptcha@^2.0.1: prop-types "^15.5.0" react-async-script "^1.1.1" +react-helmet@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726" + integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw== + dependencies: + object-assign "^4.1.1" + prop-types "^15.7.2" + react-fast-compare "^3.1.1" + react-side-effect "^2.1.0" + react-hot-loader@^4.12.21: version "4.12.21" resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.12.21.tgz#332e830801fb33024b5a147d6b13417f491eb975" @@ -5643,6 +5653,11 @@ react-router@5.1.2: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-side-effect@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.0.tgz#1ce4a8b4445168c487ed24dab886421f74d380d3" + integrity sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg== + react-transition-group@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" From 4c558a86628304b272a9736c54b9e09f36923567 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 29 Jul 2020 20:23:46 -0700 Subject: [PATCH 18/58] Fix date display for scheduled tasks; closes #2195 --- resources/scripts/components/server/schedules/ScheduleRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/scripts/components/server/schedules/ScheduleRow.tsx b/resources/scripts/components/server/schedules/ScheduleRow.tsx index ec23c6f10..514d50ac8 100644 --- a/resources/scripts/components/server/schedules/ScheduleRow.tsx +++ b/resources/scripts/components/server/schedules/ScheduleRow.tsx @@ -14,7 +14,7 @@ export default ({ schedule }: { schedule: Schedule }) => (

{schedule.name}

Last run - at: {schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM Do [at] h:mma') : 'never'} + at: {schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM do \'at\' h:mma') : 'never'}

From 874d928a50c662c9ec0ff05f1ffb1321b1ce5307 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 29 Jul 2020 20:34:06 -0700 Subject: [PATCH 19/58] Correctly handle response from daemon for server stats; #2183 --- app/Transformers/Api/Client/StatsTransformer.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/Transformers/Api/Client/StatsTransformer.php b/app/Transformers/Api/Client/StatsTransformer.php index 0fc1563a0..97989cc3a 100644 --- a/app/Transformers/Api/Client/StatsTransformer.php +++ b/app/Transformers/Api/Client/StatsTransformer.php @@ -27,11 +27,11 @@ class StatsTransformer extends BaseClientTransformer 'current_state' => Arr::get($data, 'state', 'stopped'), 'is_suspended' => Arr::get($data, 'suspended', false), 'resources' => [ - 'memory_bytes' => Arr::get($data, 'resources.memory_bytes', 0), - 'cpu_absolute' => Arr::get($data, 'resources.cpu_absolute', 0), - 'disk_bytes' => Arr::get($data, 'resources.disk_bytes', 0), - 'network_rx_bytes' => Arr::get($data, 'resources.network.rx_bytes', 0), - 'network_tx_bytes' => Arr::get($data, 'resources.network.tx_bytes', 0), + 'memory_bytes' => Arr::get($data, 'memory_bytes', 0), + 'cpu_absolute' => Arr::get($data, 'cpu_absolute', 0), + 'disk_bytes' => Arr::get($data, 'disk_bytes', 0), + 'network_rx_bytes' => Arr::get($data, 'network.rx_bytes', 0), + 'network_tx_bytes' => Arr::get($data, 'network.tx_bytes', 0), ], ]; } From 0fa90dd6bd812c33c1de73c0ed6d52bd737ca282 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 29 Jul 2020 22:02:00 -0700 Subject: [PATCH 20/58] Add listener for install start/end --- .../components/server/InstallListener.tsx | 26 +++++++++++++++++++ resources/scripts/plugins/useServer.ts | 5 ++-- resources/scripts/routers/ServerRouter.tsx | 4 ++- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 resources/scripts/components/server/InstallListener.tsx diff --git a/resources/scripts/components/server/InstallListener.tsx b/resources/scripts/components/server/InstallListener.tsx new file mode 100644 index 000000000..8bc85778a --- /dev/null +++ b/resources/scripts/components/server/InstallListener.tsx @@ -0,0 +1,26 @@ +import useWebsocketEvent from '@/plugins/useWebsocketEvent'; +import { ServerContext } from '@/state/server'; +import useServer from '@/plugins/useServer'; + +const InstallListener = () => { + const server = useServer(); + const getServer = ServerContext.useStoreActions(actions => actions.server.getServer); + const setServer = ServerContext.useStoreActions(actions => actions.server.setServer); + + // Listen for the installation completion event and then fire off a request to fetch the updated + // server information. This allows the server to automatically become available to the user if they + // just sit on the page. + useWebsocketEvent('install completed', () => { + getServer(server.uuid).catch(error => console.error(error)); + }); + + // When we see the install started event immediately update the state to indicate such so that the + // screens automatically update. + useWebsocketEvent('install started', () => { + setServer({ ...server, isInstalling: true }); + }); + + return null; +}; + +export default InstallListener; diff --git a/resources/scripts/plugins/useServer.ts b/resources/scripts/plugins/useServer.ts index 40fd93da1..8014ced58 100644 --- a/resources/scripts/plugins/useServer.ts +++ b/resources/scripts/plugins/useServer.ts @@ -1,9 +1,8 @@ -import { DependencyList } from 'react'; import { ServerContext } from '@/state/server'; import { Server } from '@/api/server/getServer'; -const useServer = (dependencies?: DependencyList): Server => { - return ServerContext.useStoreState(state => state.server.data!, [ dependencies ]); +const useServer = (dependencies?: any[] | undefined): Server => { + return ServerContext.useStoreState(state => state.server.data!, dependencies); }; export default useServer; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 9df270eaa..3fe87cca0 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -25,6 +25,7 @@ import useServer from '@/plugins/useServer'; import ScreenBlock from '@/components/screens/ScreenBlock'; import SubNavigation from '@/components/elements/SubNavigation'; import NetworkContainer from '@/components/server/network/NetworkContainer'; +import InstallListener from '@/components/server/InstallListener'; const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => { const { rootAdmin } = useStoreState(state => state.user.data!); @@ -98,6 +99,8 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
+ + {(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${server.id}`)))) ? ) /> : <> - From b92c97060b10439f4a61cc1602fad16b5feeee7d Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 1 Aug 2020 18:48:58 -0700 Subject: [PATCH 21/58] Use a key that doesn't change to avoid re-render issues; closes #2203 --- resources/scripts/api/server/files/loadDirectory.ts | 2 +- resources/scripts/api/transformers.ts | 3 +-- .../components/server/files/FileDropdownMenu.tsx | 11 +++++++---- .../components/server/files/FileManagerContainer.tsx | 2 +- .../scripts/components/server/files/FileObjectRow.tsx | 2 +- .../components/server/files/NewDirectoryButton.tsx | 3 +-- 6 files changed, 12 insertions(+), 11 deletions(-) diff --git a/resources/scripts/api/server/files/loadDirectory.ts b/resources/scripts/api/server/files/loadDirectory.ts index 7899d2216..77e44bce8 100644 --- a/resources/scripts/api/server/files/loadDirectory.ts +++ b/resources/scripts/api/server/files/loadDirectory.ts @@ -2,7 +2,7 @@ import http from '@/api/http'; import { rawDataToFileObject } from '@/api/transformers'; export interface FileObject { - uuid: string; + key: string; name: string; mode: string; size: number; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index 4548c4b1e..6ac0ba1dd 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -1,7 +1,6 @@ import { Allocation } from '@/api/server/getServer'; import { FractalResponseData } from '@/api/http'; import { FileObject } from '@/api/server/files/loadDirectory'; -import v4 from 'uuid/v4'; export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({ id: data.attributes.id, @@ -13,7 +12,7 @@ export const rawDataToServerAllocation = (data: FractalResponseData): Allocation }); export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({ - uuid: v4(), + key: `${data.attributes.is_file ? 'file' : 'dir'}_${data.attributes.name}`, name: data.attributes.name, mode: data.attributes.mode, size: Number(data.attributes.size), diff --git a/resources/scripts/components/server/files/FileDropdownMenu.tsx b/resources/scripts/components/server/files/FileDropdownMenu.tsx index 19fbe522a..e64dd3d84 100644 --- a/resources/scripts/components/server/files/FileDropdownMenu.tsx +++ b/resources/scripts/components/server/files/FileDropdownMenu.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { memo, useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faBoxOpen, @@ -29,6 +29,7 @@ import styled from 'styled-components/macro'; import useEventListener from '@/plugins/useEventListener'; import compressFiles from '@/api/server/files/compressFiles'; import decompressFiles from '@/api/server/files/decompressFiles'; +import isEqual from 'react-fast-compare'; type ModalType = 'rename' | 'move'; @@ -50,7 +51,7 @@ const Row = ({ icon, title, ...props }: RowProps) => ( ); -export default ({ file }: { file: FileObject }) => { +const FileDropdownMenu = ({ file }: { file: FileObject }) => { const onClickRef = useRef(null); const [ showSpinner, setShowSpinner ] = useState(false); const [ modal, setModal ] = useState(null); @@ -60,7 +61,7 @@ export default ({ file }: { file: FileObject }) => { const { clearAndAddHttpError, clearFlashes } = useFlash(); const directory = ServerContext.useStoreState(state => state.files.directory); - useEventListener(`pterodactyl:files:ctx:${file.uuid}`, (e: CustomEvent) => { + useEventListener(`pterodactyl:files:ctx:${file.key}`, (e: CustomEvent) => { if (onClickRef.current) { onClickRef.current.triggerMenu(e.detail); } @@ -71,7 +72,7 @@ export default ({ file }: { file: FileObject }) => { // For UI speed, immediately remove the file from the listing before calling the deletion function. // If the delete actually fails, we'll fetch the current directory contents again automatically. - mutate(files => files.filter(f => f.uuid !== file.uuid), false); + mutate(files => files.filter(f => f.key !== file.key), false); deleteFiles(uuid, directory, [ file.name ]).catch(error => { mutate(); @@ -166,3 +167,5 @@ export default ({ file }: { file: FileObject }) => { ); }; + +export default memo(FileDropdownMenu, isEqual); diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index def0944c2..a6d6b89af 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -65,7 +65,7 @@ export default () => { } { sortFiles(files.slice(0, 250)).map(file => ( - + )) } diff --git a/resources/scripts/components/server/files/FileObjectRow.tsx b/resources/scripts/components/server/files/FileObjectRow.tsx index a78a83cc1..0a14aca8c 100644 --- a/resources/scripts/components/server/files/FileObjectRow.tsx +++ b/resources/scripts/components/server/files/FileObjectRow.tsx @@ -39,7 +39,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => { key={file.name} onContextMenu={e => { e.preventDefault(); - window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX })); + window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.key}`, { detail: e.clientX })); }} > diff --git a/resources/scripts/components/server/files/NewDirectoryButton.tsx b/resources/scripts/components/server/files/NewDirectoryButton.tsx index 9adbf57dd..27cfb15f7 100644 --- a/resources/scripts/components/server/files/NewDirectoryButton.tsx +++ b/resources/scripts/components/server/files/NewDirectoryButton.tsx @@ -6,7 +6,6 @@ import Field from '@/components/elements/Field'; import { join } from 'path'; import { object, string } from 'yup'; import createDirectory from '@/api/server/files/createDirectory'; -import v4 from 'uuid/v4'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import { mutate } from 'swr'; @@ -24,7 +23,7 @@ const schema = object().shape({ }); const generateDirectoryData = (name: string): FileObject => ({ - uuid: v4(), + key: `dir_${name}`, name: name, mode: '0644', size: 0, From 0c7f118f45955638cabc4d1eb597648aa74a1e06 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 1 Aug 2020 19:44:50 -0700 Subject: [PATCH 22/58] add withFlash() context HOC --- .../server/files/RenameFileModal.tsx | 13 ++++++----- resources/scripts/hoc/withFlash.tsx | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) create mode 100644 resources/scripts/hoc/withFlash.tsx diff --git a/resources/scripts/components/server/files/RenameFileModal.tsx b/resources/scripts/components/server/files/RenameFileModal.tsx index 155b45e9b..8ecbc9d91 100644 --- a/resources/scripts/components/server/files/RenameFileModal.tsx +++ b/resources/scripts/components/server/files/RenameFileModal.tsx @@ -9,23 +9,22 @@ import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import useServer from '@/plugins/useServer'; import useFileManagerSwr from '@/plugins/useFileManagerSwr'; -import useFlash from '@/plugins/useFlash'; +import withFlash, { WithFlashProps } from '@/hoc/withFlash'; interface FormikValues { name: string; } -type Props = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean }; +type OwnProps = WithFlashProps & RequiredModalProps & { files: string[]; useMoveTerminology?: boolean }; -export default ({ files, useMoveTerminology, ...props }: Props) => { +const RenameFileModal = ({ flash, files, useMoveTerminology, ...props }: OwnProps) => { const { uuid } = useServer(); const { mutate } = useFileManagerSwr(); - const { clearFlashes, clearAndAddHttpError } = useFlash(); const directory = ServerContext.useStoreState(state => state.files.directory); const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles); const submit = ({ name }: FormikValues, { setSubmitting }: FormikHelpers) => { - clearFlashes('files'); + flash.clearFlashes('files'); const len = name.split('/').length; if (files.length === 1) { @@ -51,7 +50,7 @@ export default ({ files, useMoveTerminology, ...props }: Props) => { .catch(error => { mutate(); setSubmitting(false); - clearAndAddHttpError({ key: 'files', error }); + flash.clearAndAddHttpError({ key: 'files', error }); }) .then(() => props.onDismissed()); }; @@ -96,3 +95,5 @@ export default ({ files, useMoveTerminology, ...props }: Props) => { ); }; + +export default withFlash(RenameFileModal); diff --git a/resources/scripts/hoc/withFlash.tsx b/resources/scripts/hoc/withFlash.tsx new file mode 100644 index 000000000..4a3f008f4 --- /dev/null +++ b/resources/scripts/hoc/withFlash.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import useFlash from '@/plugins/useFlash'; +import { Actions } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; + +export interface WithFlashProps { + flash: Actions['flashes']; +} + +function withFlash (Component: React.ComponentType): React.ComponentType { + return (props: TOwnProps) => { + const { addError, addFlash, clearFlashes, clearAndAddHttpError } = useFlash(); + + return ( + + ); + }; +} + +export default withFlash; From c58348735d85c0be774a44a77eece89307492a0d Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 1 Aug 2020 19:49:38 -0700 Subject: [PATCH 23/58] Avoid double-click double-submit issues in modals; closes #2199 --- .../components/server/backups/CreateBackupButton.tsx | 8 ++------ .../components/server/schedules/EditScheduleModal.tsx | 2 +- .../components/server/schedules/TaskDetailsModal.tsx | 4 ++-- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx index 7a04f1041..3d7834fa9 100644 --- a/resources/scripts/components/server/backups/CreateBackupButton.tsx +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -49,7 +49,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
-
@@ -94,11 +94,7 @@ export default () => { ignored: string(), })} > - setVisible(false)} - /> + setVisible(false)}/> }
-
diff --git a/resources/scripts/components/server/schedules/TaskDetailsModal.tsx b/resources/scripts/components/server/schedules/TaskDetailsModal.tsx index 3829d724d..b8b102ba0 100644 --- a/resources/scripts/components/server/schedules/TaskDetailsModal.tsx +++ b/resources/scripts/components/server/schedules/TaskDetailsModal.tsx @@ -32,7 +32,7 @@ interface Values { } const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => { - const { values: { action }, setFieldValue, setFieldTouched } = useFormikContext(); + const { values: { action }, setFieldValue, setFieldTouched, isSubmitting } = useFormikContext(); useEffect(() => { setFieldValue('payload', action === 'power' ? 'start' : ''); @@ -94,7 +94,7 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => { />
-
From a9666138907e06f531973c65d2576e0b6b68935b Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 1 Aug 2020 19:52:13 -0700 Subject: [PATCH 24/58] Fix task edit modal not filling the payload correctly --- .../components/server/schedules/TaskDetailsModal.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/resources/scripts/components/server/schedules/TaskDetailsModal.tsx b/resources/scripts/components/server/schedules/TaskDetailsModal.tsx index b8b102ba0..00457a4ec 100644 --- a/resources/scripts/components/server/schedules/TaskDetailsModal.tsx +++ b/resources/scripts/components/server/schedules/TaskDetailsModal.tsx @@ -32,11 +32,16 @@ interface Values { } const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => { - const { values: { action }, setFieldValue, setFieldTouched, isSubmitting } = useFormikContext(); + const { values: { action }, initialValues, setFieldValue, setFieldTouched, isSubmitting } = useFormikContext(); useEffect(() => { - setFieldValue('payload', action === 'power' ? 'start' : ''); - setFieldTouched('payload', false); + if (action !== initialValues.action) { + setFieldValue('payload', action === 'power' ? 'start' : ''); + setFieldTouched('payload', false); + } else { + setFieldValue('payload', initialValues.payload); + setFieldTouched('payload', false); + } }, [ action ]); return ( From dd381f65a92a094dbadb25697b3a6cddf1d6abc0 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 1 Aug 2020 20:06:17 -0700 Subject: [PATCH 25/58] Don't try to be fancy, just pain --- .../server/files/RenameFileModal.tsx | 13 ++++++----- resources/scripts/hoc/withFlash.tsx | 23 ------------------- 2 files changed, 7 insertions(+), 29 deletions(-) delete mode 100644 resources/scripts/hoc/withFlash.tsx diff --git a/resources/scripts/components/server/files/RenameFileModal.tsx b/resources/scripts/components/server/files/RenameFileModal.tsx index 8ecbc9d91..fb3c0620d 100644 --- a/resources/scripts/components/server/files/RenameFileModal.tsx +++ b/resources/scripts/components/server/files/RenameFileModal.tsx @@ -9,22 +9,23 @@ import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import useServer from '@/plugins/useServer'; import useFileManagerSwr from '@/plugins/useFileManagerSwr'; -import withFlash, { WithFlashProps } from '@/hoc/withFlash'; +import useFlash from '@/plugins/useFlash'; interface FormikValues { name: string; } -type OwnProps = WithFlashProps & RequiredModalProps & { files: string[]; useMoveTerminology?: boolean }; +type OwnProps = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean }; -const RenameFileModal = ({ flash, files, useMoveTerminology, ...props }: OwnProps) => { +const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => { const { uuid } = useServer(); const { mutate } = useFileManagerSwr(); + const { clearFlashes, clearAndAddHttpError } = useFlash(); const directory = ServerContext.useStoreState(state => state.files.directory); const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles); const submit = ({ name }: FormikValues, { setSubmitting }: FormikHelpers) => { - flash.clearFlashes('files'); + clearFlashes('files'); const len = name.split('/').length; if (files.length === 1) { @@ -50,7 +51,7 @@ const RenameFileModal = ({ flash, files, useMoveTerminology, ...props }: OwnProp .catch(error => { mutate(); setSubmitting(false); - flash.clearAndAddHttpError({ key: 'files', error }); + clearAndAddHttpError({ key: 'files', error }); }) .then(() => props.onDismissed()); }; @@ -96,4 +97,4 @@ const RenameFileModal = ({ flash, files, useMoveTerminology, ...props }: OwnProp ); }; -export default withFlash(RenameFileModal); +export default RenameFileModal; diff --git a/resources/scripts/hoc/withFlash.tsx b/resources/scripts/hoc/withFlash.tsx deleted file mode 100644 index 4a3f008f4..000000000 --- a/resources/scripts/hoc/withFlash.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import useFlash from '@/plugins/useFlash'; -import { Actions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; - -export interface WithFlashProps { - flash: Actions['flashes']; -} - -function withFlash (Component: React.ComponentType): React.ComponentType { - return (props: TOwnProps) => { - const { addError, addFlash, clearFlashes, clearAndAddHttpError } = useFlash(); - - return ( - - ); - }; -} - -export default withFlash; From d3316f61d7d0f61890ca4d7c3bb706cf3fe7c2ff Mon Sep 17 00:00:00 2001 From: Charles Morgan Date: Sat, 1 Aug 2020 23:49:00 -0400 Subject: [PATCH 26/58] Titles on index / account pages Also changed to use `const { ..., name: serverName } = useServer();` where feasible --- .../scripts/components/dashboard/AccountApiContainer.tsx | 7 ++++++- .../components/dashboard/AccountOverviewContainer.tsx | 7 +++++++ .../scripts/components/dashboard/DashboardContainer.tsx | 6 ++++++ .../scripts/components/server/backups/BackupContainer.tsx | 5 ++--- .../components/server/databases/DatabasesContainer.tsx | 5 ++--- .../components/server/files/FileManagerContainer.tsx | 5 ++--- .../scripts/components/server/network/NetworkContainer.tsx | 7 ++----- .../components/server/schedules/ScheduleContainer.tsx | 5 ++--- .../scripts/components/server/users/UsersContainer.tsx | 2 +- 9 files changed, 30 insertions(+), 19 deletions(-) diff --git a/resources/scripts/components/dashboard/AccountApiContainer.tsx b/resources/scripts/components/dashboard/AccountApiContainer.tsx index f3ceb66f0..c80a51a20 100644 --- a/resources/scripts/components/dashboard/AccountApiContainer.tsx +++ b/resources/scripts/components/dashboard/AccountApiContainer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import ContentBox from '@/components/elements/ContentBox'; import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm'; import getApiKeys, { ApiKey } from '@/api/account/getApiKeys'; @@ -7,7 +8,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import ConfirmationModal from '@/components/elements/ConfirmationModal'; import deleteApiKey from '@/api/account/deleteApiKey'; -import { Actions, useStoreActions } from 'easy-peasy'; +import { Actions, useStoreActions, useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; import FlashMessageRender from '@/components/FlashMessageRender'; import { httpErrorToHuman } from '@/api/http'; @@ -21,6 +22,7 @@ export default () => { const [ keys, setKeys ] = useState([]); const [ loading, setLoading ] = useState(true); const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); useEffect(() => { clearFlashes('account'); @@ -49,6 +51,9 @@ export default () => { return ( + + {name} | API +
diff --git a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx index e98ddd4a6..d495400b4 100644 --- a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx +++ b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx @@ -1,4 +1,6 @@ import * as React from 'react'; +import { Helmet } from 'react-helmet'; +import { ApplicationStore } from '@/state'; import ContentBox from '@/components/elements/ContentBox'; import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm'; import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm'; @@ -7,6 +9,7 @@ import PageContentBlock from '@/components/elements/PageContentBlock'; import tw from 'twin.macro'; import { breakpoint } from '@/theme'; import styled from 'styled-components/macro'; +import { useStoreState } from 'easy-peasy'; const Container = styled.div` ${tw`flex flex-wrap my-10`}; @@ -25,8 +28,12 @@ const Container = styled.div` `; export default () => { + const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); return ( + + {name} | Account Overview + diff --git a/resources/scripts/components/dashboard/DashboardContainer.tsx b/resources/scripts/components/dashboard/DashboardContainer.tsx index a20472604..1e1e702ca 100644 --- a/resources/scripts/components/dashboard/DashboardContainer.tsx +++ b/resources/scripts/components/dashboard/DashboardContainer.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import { Server } from '@/api/server/getServer'; +import { ApplicationStore } from '@/state'; import getServers from '@/api/getServers'; import ServerRow from '@/components/dashboard/ServerRow'; import Spinner from '@/components/elements/Spinner'; @@ -18,6 +20,7 @@ export default () => { const [ page, setPage ] = useState(1); const { rootAdmin } = useStoreState(state => state.user.data!); const [ showOnlyAdmin, setShowOnlyAdmin ] = usePersistedState('show_all_servers', false); + const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); const { data: servers, error } = useSWR>( [ '/api/client/servers', showOnlyAdmin, page ], @@ -31,6 +34,9 @@ export default () => { return ( + + {name} | Dashboard + {rootAdmin &&

diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index c8abddcae..bcead7abb 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -14,12 +14,11 @@ import PageContentBlock from '@/components/elements/PageContentBlock'; import tw from 'twin.macro'; export default () => { - const { uuid, featureLimits } = useServer(); + const { uuid, featureLimits, name: serverName } = useServer(); const { addError, clearFlashes } = useFlash(); const [ loading, setLoading ] = useState(true); const backups = ServerContext.useStoreState(state => state.backups.data); - const server = ServerContext.useStoreState(state => state.server.data!); const setBackups = ServerContext.useStoreActions(actions => actions.backups.setBackups); useEffect(() => { @@ -40,7 +39,7 @@ export default () => { return ( - {server.name} | Backups + {serverName} | Backups {!backups.length ? diff --git a/resources/scripts/components/server/databases/DatabasesContainer.tsx b/resources/scripts/components/server/databases/DatabasesContainer.tsx index 462d90fb1..922f0a364 100644 --- a/resources/scripts/components/server/databases/DatabasesContainer.tsx +++ b/resources/scripts/components/server/databases/DatabasesContainer.tsx @@ -15,12 +15,11 @@ import tw from 'twin.macro'; import Fade from '@/components/elements/Fade'; export default () => { - const { uuid, featureLimits } = useServer(); + const { uuid, featureLimits, name: serverName } = useServer(); const { addError, clearFlashes } = useFlash(); const [ loading, setLoading ] = useState(true); const databases = ServerContext.useStoreState(state => state.databases.data); - const servername = ServerContext.useStoreState(state => state.server.data.name); const setDatabases = ServerContext.useStoreActions(state => state.databases.setDatabases); useEffect(() => { @@ -39,7 +38,7 @@ export default () => { return ( - {servername} | Databases + {serverName} | Databases {(!databases.length && loading) ? diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index 380a09513..d25ef3c1c 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -24,11 +24,10 @@ const sortFiles = (files: FileObject[]): FileObject[] => { }; export default () => { - const { id } = useServer(); + const { id, name: serverName } = useServer(); const { hash } = useLocation(); const { data: files, error, mutate } = useFileManagerSwr(); - const servername = ServerContext.useStoreState(state => state.server.data.name); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles); @@ -46,7 +45,7 @@ export default () => { return ( - {servername} | File Manager + {serverName} | File Manager { diff --git a/resources/scripts/components/server/network/NetworkContainer.tsx b/resources/scripts/components/server/network/NetworkContainer.tsx index 4470f681e..a330685b1 100644 --- a/resources/scripts/components/server/network/NetworkContainer.tsx +++ b/resources/scripts/components/server/network/NetworkContainer.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; -import { ServerContext } from '@/state/server'; import tw from 'twin.macro'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; @@ -25,13 +24,11 @@ const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`; const NetworkContainer = () => { - const { uuid, allocations } = useServer(); + const { uuid, allocations, name: serverName } = useServer(); const { clearFlashes, clearAndAddHttpError } = useFlash(); const [ loading, setLoading ] = useState(false); const { data, error, mutate } = useSWR(uuid, key => getServerAllocations(key), { initialData: allocations }); - const servername = ServerContext.useStoreState(state => state.server.data.name); - const setPrimaryAllocation = (id: number) => { clearFlashes('server:network'); @@ -66,7 +63,7 @@ const NetworkContainer = () => { return ( - {servername} | Network + {serverName} | Network {!data ? diff --git a/resources/scripts/components/server/schedules/ScheduleContainer.tsx b/resources/scripts/components/server/schedules/ScheduleContainer.tsx index 2ee4038cc..77e31b590 100644 --- a/resources/scripts/components/server/schedules/ScheduleContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleContainer.tsx @@ -17,13 +17,12 @@ import GreyRowBox from '@/components/elements/GreyRowBox'; import Button from '@/components/elements/Button'; export default ({ match, history }: RouteComponentProps) => { - const { uuid } = useServer(); + const { uuid, name: serverName } = useServer(); const { clearFlashes, addError } = useFlash(); const [ loading, setLoading ] = useState(true); const [ visible, setVisible ] = useState(false); const schedules = ServerContext.useStoreState(state => state.schedules.data); - const servername = ServerContext.useStoreState(state => state.server.data.name); const setSchedules = ServerContext.useStoreActions(actions => actions.schedules.setSchedules); useEffect(() => { @@ -40,7 +39,7 @@ export default ({ match, history }: RouteComponentProps) => { return ( - {servername} | Schedules + {serverName} | Schedules {(!schedules.length && loading) ? diff --git a/resources/scripts/components/server/users/UsersContainer.tsx b/resources/scripts/components/server/users/UsersContainer.tsx index 0925e87df..a58d9e904 100644 --- a/resources/scripts/components/server/users/UsersContainer.tsx +++ b/resources/scripts/components/server/users/UsersContainer.tsx @@ -18,7 +18,7 @@ export default () => { const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const subusers = ServerContext.useStoreState(state => state.subusers.data); - const servername = ServerContext.useStoreState(state => state.server.data.name); + const servername = ServerContext.useStoreState(state => state.server.data!.name); const setSubusers = ServerContext.useStoreActions(actions => actions.subusers.setSubusers); const permissions = useStoreState((state: ApplicationStore) => state.permissions.data); From b52fc0b4d9532d967b677a4bb0cbbb7db53416db Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 1 Aug 2020 21:08:35 -0700 Subject: [PATCH 27/58] Fix recaptcha handling during login & password reset flows; closes #2064 --- package.json | 5 +- .../api/auth/requestPasswordResetEmail.ts | 4 +- .../auth/ForgotPasswordContainer.tsx | 40 +++- .../components/auth/LoginContainer.tsx | 187 ++++++++---------- resources/scripts/state/flashes.ts | 2 +- routes/auth.php | 2 +- yarn.lock | 27 +-- 7 files changed, 131 insertions(+), 136 deletions(-) diff --git a/package.json b/package.json index 3a81f98fa..0ca8c2e9b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "@fortawesome/fontawesome-svg-core": "1.2.19", "@fortawesome/free-solid-svg-icons": "^5.9.0", "@fortawesome/react-fontawesome": "0.1.4", - "@types/react-google-recaptcha": "^1.1.1", "axios": "^0.19.2", "ayu-ace": "^2.0.4", "brace": "^0.11.1", @@ -23,15 +22,15 @@ "path": "^0.12.7", "query-string": "^6.7.0", "react": "^16.13.1", - "react-ga": "^3.1.2", "react-dom": "npm:@hot-loader/react-dom", "react-fast-compare": "^3.2.0", - "react-google-recaptcha": "^2.0.1", + "react-ga": "^3.1.2", "react-hot-loader": "^4.12.21", "react-i18next": "^11.2.1", "react-redux": "^7.1.0", "react-router-dom": "^5.1.2", "react-transition-group": "^4.4.1", + "reaptcha": "^1.7.2", "sockette": "^2.0.6", "styled-components": "^5.1.1", "styled-components-breakpoint": "^3.0.0-preview.20", diff --git a/resources/scripts/api/auth/requestPasswordResetEmail.ts b/resources/scripts/api/auth/requestPasswordResetEmail.ts index d70139899..2168160c2 100644 --- a/resources/scripts/api/auth/requestPasswordResetEmail.ts +++ b/resources/scripts/api/auth/requestPasswordResetEmail.ts @@ -1,8 +1,8 @@ import http from '@/api/http'; -export default (email: string): Promise => { +export default (email: string, recaptchaData?: string): Promise => { return new Promise((resolve, reject) => { - http.post('/auth/password', { email }) + http.post('/auth/password', { email, 'g-recaptcha-response': recaptchaData }) .then(response => resolve(response.data.status || '')) .catch(reject); }); diff --git a/resources/scripts/components/auth/ForgotPasswordContainer.tsx b/resources/scripts/components/auth/ForgotPasswordContainer.tsx index dbd4ed469..82bd5e5ff 100644 --- a/resources/scripts/components/auth/ForgotPasswordContainer.tsx +++ b/resources/scripts/components/auth/ForgotPasswordContainer.tsx @@ -1,27 +1,40 @@ import * as React from 'react'; +import { useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail'; import { httpErrorToHuman } from '@/api/http'; import LoginFormContainer from '@/components/auth/LoginFormContainer'; -import { Actions, useStoreActions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; +import { useStoreState } from 'easy-peasy'; import Field from '@/components/elements/Field'; import { Formik, FormikHelpers } from 'formik'; import { object, string } from 'yup'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; +import Reaptcha from 'reaptcha'; +import useFlash from '@/plugins/useFlash'; interface Values { email: string; } export default () => { - const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); + const ref = useRef(null); + const [ token, setToken ] = useState(''); + + const { clearFlashes, addFlash } = useFlash(); + const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha); const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers) => { - setSubmitting(true); clearFlashes(); - requestPasswordResetEmail(email) + + // If there is no token in the state yet, request the token and then abort this submit request + // since it will be re-submitted when the recaptcha data is returned by the component. + if (recaptchaEnabled && !token) { + ref.current!.execute().catch(error => console.error(error)); + return; + } + + requestPasswordResetEmail(email, token) .then(response => { resetForm(); addFlash({ type: 'success', title: 'Success', message: response }); @@ -42,7 +55,7 @@ export default () => { .required('A valid email address must be provided to continue.'), })} > - {({ isSubmitting }) => ( + {({ isSubmitting, setSubmitting, submitForm }) => ( { Send Email

+ {recaptchaEnabled && + { + setToken(response); + submitForm(); + }} + onExpire={() => { + setSubmitting(false); + setToken(''); + }} + /> + }
; - addFlash: ActionCreator; +interface Values { + username: string; + password: string; } -const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handleSubmit }: OwnProps & FormikProps) => { - const ref = useRef(null); - const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha); +const LoginContainer = ({ history }: RouteComponentProps) => { + const ref = useRef(null); + const [ token, setToken ] = useState(''); - const submit = (e: React.FormEvent) => { - e.preventDefault(); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha); - if (ref.current && !values.recaptchaData) { - return ref.current.execute(); + const onSubmit = (values: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes(); + + // If there is no token in the state yet, request the token and then abort this submit request + // since it will be re-submitted when the recaptcha data is returned by the component. + if (recaptchaEnabled && !token) { + ref.current!.execute().catch(error => console.error(error)); + return; } - handleSubmit(e); - }; - - return ( - - {ref.current && ref.current.render()} - - -
- -
-
- -
- {recaptchaEnabled && - { - ref.current && ref.current.reset(); - setFieldValue('recaptchaData', token); - submitForm(); - }} - onExpired={() => setFieldValue('recaptchaData', null)} - /> - } -
- - Forgot password? - -
-
-
- ); -}; - -const EnhancedForm = withFormik({ - displayName: 'LoginContainerForm', - - mapPropsToValues: () => ({ - username: '', - password: '', - recaptchaData: null, - }), - - validationSchema: () => object().shape({ - username: string().required('A username or email must be provided.'), - password: string().required('Please enter your account password.'), - }), - - handleSubmit: (values, { props, setFieldValue, setSubmitting }) => { - props.clearFlashes(); - login(values) + login({ ...values, recaptchaData: token }) .then(response => { if (response.complete) { // @ts-ignore @@ -107,26 +41,75 @@ const EnhancedForm = withFormik({ return; } - props.history.replace('/auth/login/checkpoint', { token: response.confirmationToken }); + history.replace('/auth/login/checkpoint', { token: response.confirmationToken }); }) .catch(error => { console.error(error); setSubmitting(false); - setFieldValue('recaptchaData', null); - props.addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) }); + clearAndAddHttpError({ error }); }); - }, -})(LoginContainer); - -export default (props: RouteComponentProps) => { - const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); + }; return ( - + + {({ isSubmitting, setSubmitting, submitForm }) => ( + + +
+ +
+
+ +
+ {recaptchaEnabled && + { + setToken(response); + submitForm(); + }} + onExpire={() => { + setSubmitting(false); + setToken(''); + }} + /> + } +
+ + Forgot password? + +
+
+ )} +
); }; + +export default LoginContainer; diff --git a/resources/scripts/state/flashes.ts b/resources/scripts/state/flashes.ts index 8e4fb258e..fb89a0a8d 100644 --- a/resources/scripts/state/flashes.ts +++ b/resources/scripts/state/flashes.ts @@ -6,7 +6,7 @@ export interface FlashStore { items: FlashMessage[]; addFlash: Action; addError: Action; - clearAndAddHttpError: Action; + clearAndAddHttpError: Action; clearFlashes: Action; } diff --git a/routes/auth.php b/routes/auth.php index a6038447b..4bdb72206 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -26,7 +26,7 @@ Route::group(['middleware' => 'guest'], function () { // Password reset routes. This endpoint is hit after going through // the forgot password routes to acquire a token (or after an account // is created). - Route::post('/password/reset', 'ResetPasswordController')->name('auth.reset-password')->middleware('recaptcha'); + Route::post('/password/reset', 'ResetPasswordController')->name('auth.reset-password'); // Catch any other combinations of routes and pass them off to the Vuejs component. Route::fallback('LoginController@index'); diff --git a/yarn.lock b/yarn.lock index f20fef049..73e239aef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1013,12 +1013,6 @@ dependencies: "@types/react" "*" -"@types/react-google-recaptcha@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@types/react-google-recaptcha/-/react-google-recaptcha-1.1.1.tgz#7dd2a4dd15d38d8059a2753cd4a7e3485c9bb3ea" - dependencies: - "@types/react" "*" - "@types/react-native@*": version "0.60.2" resolved "https://registry.yarnpkg.com/@types/react-native/-/react-native-0.60.2.tgz#2dca78481a904419c2a5907288dd97d1090c6e3c" @@ -5399,7 +5393,7 @@ promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" -prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.10, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -5544,13 +5538,6 @@ rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-async-script@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/react-async-script/-/react-async-script-1.1.1.tgz#f481c6c5f094bf4b94a9d52da0d0dda2e1a74bdf" - dependencies: - hoist-non-react-statics "^3.3.0" - prop-types "^15.5.0" - "react-dom@npm:@hot-loader/react-dom": version "16.11.0" resolved "https://registry.yarnpkg.com/@hot-loader/react-dom/-/react-dom-16.11.0.tgz#c0b483923b289db5431516f56ee2a69448ebf9bd" @@ -5574,13 +5561,6 @@ react-ga@^3.1.2: resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-3.1.2.tgz#e13f211c51a2e5c401ea69cf094b9501fe3c51ce" integrity sha512-OJrMqaHEHbodm+XsnjA6ISBEHTwvpFrxco65mctzl/v3CASMSLSyUkFqz9yYrPDKGBUfNQzKCjuMJwctjlWBbw== -react-google-recaptcha@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-2.0.1.tgz#3276b29659493f7ca2a5b7739f6c239293cdf1d8" - dependencies: - prop-types "^15.5.0" - react-async-script "^1.1.1" - react-hot-loader@^4.12.21: version "4.12.21" resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.12.21.tgz#332e830801fb33024b5a147d6b13417f491eb975" @@ -5719,6 +5699,11 @@ readdirp@~3.4.0: dependencies: picomatch "^2.2.1" +reaptcha@^1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/reaptcha/-/reaptcha-1.7.2.tgz#d829f54270c241f46501e92a5a7badeb1fcf372d" + integrity sha512-/RXiPeMd+fPUGByv+kAaQlCXCsSflZ9bKX5Fcwv9IYGS1oyT2nntL/8zn9IaiUFHL66T1jBtOABcb92g2+3w8w== + reduce-css-calc@^2.1.6: version "2.1.7" resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-2.1.7.tgz#1ace2e02c286d78abcd01fd92bfe8097ab0602c2" From a1f1e4294df6b70fb438b0d349396bd50a42a5d8 Mon Sep 17 00:00:00 2001 From: Charles Morgan Date: Sun, 2 Aug 2020 00:11:49 -0400 Subject: [PATCH 28/58] conflict fix --- package.json | 3 +-- yarn.lock | 17 +++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index b69ecba10..401bcc7da 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "@fortawesome/fontawesome-svg-core": "1.2.19", "@fortawesome/free-solid-svg-icons": "^5.9.0", "@fortawesome/react-fontawesome": "0.1.4", - "@types/react-google-recaptcha": "^1.1.1", "axios": "^0.19.2", "ayu-ace": "^2.0.4", "brace": "^0.11.1", @@ -23,7 +22,6 @@ "path": "^0.12.7", "query-string": "^6.7.0", "react": "^16.13.1", - "react-ga": "^3.1.2", "react-dom": "npm:@hot-loader/react-dom", "react-fast-compare": "^3.2.0", "react-google-recaptcha": "^2.0.1", @@ -33,6 +31,7 @@ "react-redux": "^7.1.0", "react-router-dom": "^5.1.2", "react-transition-group": "^4.4.1", + "reaptcha": "^1.7.2", "sockette": "^2.0.6", "styled-components": "^5.1.1", "styled-components-breakpoint": "^3.0.0-preview.20", diff --git a/yarn.lock b/yarn.lock index a717a0a9b..0bba5d32b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1013,9 +1013,10 @@ dependencies: "@types/react" "*" -"@types/react-google-recaptcha@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@types/react-google-recaptcha/-/react-google-recaptcha-1.1.1.tgz#7dd2a4dd15d38d8059a2753cd4a7e3485c9bb3ea" +"@types/react-helmet@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@types/react-helmet/-/react-helmet-6.0.0.tgz#5b74e44a12662ffb12d1c97ee702cf4e220958cf" + integrity sha512-NBMPAxgjpaMooXa51cU1BTgrX6T+hQbMiLm77JhBbfOzPQea3RB5rNpPOD5xGWHIVpGXHd59cltEzIq0qglGcQ== dependencies: "@types/react" "*" @@ -5569,11 +5570,6 @@ react-fast-compare@^3.1.1, react-fast-compare@^3.2.0: resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== -react-ga@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-3.1.2.tgz#e13f211c51a2e5c401ea69cf094b9501fe3c51ce" - integrity sha512-OJrMqaHEHbodm+XsnjA6ISBEHTwvpFrxco65mctzl/v3CASMSLSyUkFqz9yYrPDKGBUfNQzKCjuMJwctjlWBbw== - react-google-recaptcha@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-2.0.1.tgz#3276b29659493f7ca2a5b7739f6c239293cdf1d8" @@ -5734,6 +5730,11 @@ readdirp@~3.4.0: dependencies: picomatch "^2.2.1" +reaptcha@^1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/reaptcha/-/reaptcha-1.7.2.tgz#d829f54270c241f46501e92a5a7badeb1fcf372d" + integrity sha512-/RXiPeMd+fPUGByv+kAaQlCXCsSflZ9bKX5Fcwv9IYGS1oyT2nntL/8zn9IaiUFHL66T1jBtOABcb92g2+3w8w== + reduce-css-calc@^2.1.6: version "2.1.7" resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-2.1.7.tgz#1ace2e02c286d78abcd01fd92bfe8097ab0602c2" From 9387be3b0d277a0cdb985d506ff65f81caad6853 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 1 Aug 2020 21:25:28 -0700 Subject: [PATCH 29/58] Fix permissions on subuser rows --- .../components/server/users/UserRow.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/resources/scripts/components/server/users/UserRow.tsx b/resources/scripts/components/server/users/UserRow.tsx index 165d0f9c7..346b083e1 100644 --- a/resources/scripts/components/server/users/UserRow.tsx +++ b/resources/scripts/components/server/users/UserRow.tsx @@ -51,17 +51,18 @@ export default ({ subuser }: Props) => {

Permissions

- + + {subuser.uuid !== uuid && + + } + From 26704a2d5f8873f6bcf24f9214e263470b6d666a Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Mon, 3 Aug 2020 20:58:15 -0700 Subject: [PATCH 30/58] Clear reinstall messages when mounting; closes #2213 --- .../components/server/settings/ReinstallServerBox.tsx | 6 +++++- yarn.lock | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/resources/scripts/components/server/settings/ReinstallServerBox.tsx b/resources/scripts/components/server/settings/ReinstallServerBox.tsx index eef96c16c..1b7b44de7 100644 --- a/resources/scripts/components/server/settings/ReinstallServerBox.tsx +++ b/resources/scripts/components/server/settings/ReinstallServerBox.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { ServerContext } from '@/state/server'; import TitledGreyBox from '@/components/elements/TitledGreyBox'; import ConfirmationModal from '@/components/elements/ConfirmationModal'; @@ -37,6 +37,10 @@ export default () => { }); }; + useEffect(() => { + clearFlashes(); + }, []); + return ( Date: Tue, 4 Aug 2020 20:34:44 -0700 Subject: [PATCH 31/58] Return egg "done" checks as an array rather than a string --- app/Services/Eggs/EggConfigurationService.php | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/app/Services/Eggs/EggConfigurationService.php b/app/Services/Eggs/EggConfigurationService.php index 3d98cc33c..2659259b0 100644 --- a/app/Services/Eggs/EggConfigurationService.php +++ b/app/Services/Eggs/EggConfigurationService.php @@ -51,12 +51,30 @@ class EggConfigurationService ); return [ - 'startup' => json_decode($server->egg->inherit_config_startup), + 'startup' => $this->convertStartupToNewFormat(json_decode($server->egg->inherit_config_startup, true)), 'stop' => $this->convertStopToNewFormat($server->egg->inherit_config_stop), 'configs' => $configs, ]; } + /** + * Convert the "done" variable into an array if it is not currently one. + * + * @param array $startup + * @return array + */ + protected function convertStartupToNewFormat(array $startup) + { + $done = Arr::get($startup, 'done'); + + return array_filter([ + 'done' => is_string($done) ? [$done] : $done, + 'user_interaction' => Arr::get($startup, 'userInteraction'), + ], function ($datum) { + return ! is_null($datum); + }); + } + /** * Converts a legacy stop string into a new generation stop option for a server. * From c91c02f6a84936aab352960641daafd4bc139567 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Tue, 4 Aug 2020 20:38:24 -0700 Subject: [PATCH 32/58] Fix for struct in Go --- app/Services/Eggs/EggConfigurationService.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/Services/Eggs/EggConfigurationService.php b/app/Services/Eggs/EggConfigurationService.php index 2659259b0..9d30f8a86 100644 --- a/app/Services/Eggs/EggConfigurationService.php +++ b/app/Services/Eggs/EggConfigurationService.php @@ -67,12 +67,11 @@ class EggConfigurationService { $done = Arr::get($startup, 'done'); - return array_filter([ + return [ 'done' => is_string($done) ? [$done] : $done, - 'user_interaction' => Arr::get($startup, 'userInteraction'), - ], function ($datum) { - return ! is_null($datum); - }); + 'user_interaction' => Arr::get($startup, 'userInteraction') ?? [], + 'strip_ansi' => Arr::get($startup, 'strip_ansi') ?? false, + ]; } /** From d1a28051f9dab52f0880134af25dbec96ab8f181 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Tue, 4 Aug 2020 20:39:18 -0700 Subject: [PATCH 33/58] Support userInteraction and user_interaction because who needs this to be maintainable in the future... --- app/Services/Eggs/EggConfigurationService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Services/Eggs/EggConfigurationService.php b/app/Services/Eggs/EggConfigurationService.php index 9d30f8a86..6f4eae689 100644 --- a/app/Services/Eggs/EggConfigurationService.php +++ b/app/Services/Eggs/EggConfigurationService.php @@ -69,7 +69,7 @@ class EggConfigurationService return [ 'done' => is_string($done) ? [$done] : $done, - 'user_interaction' => Arr::get($startup, 'userInteraction') ?? [], + 'user_interaction' => Arr::get($startup, 'userInteraction') ?? Arr::get($startup, 'user_interaction') ?? [], 'strip_ansi' => Arr::get($startup, 'strip_ansi') ?? false, ]; } From 95e8492c5dea75f62562ce333d09c6fe3d7b4966 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Thu, 6 Aug 2020 20:25:35 -0700 Subject: [PATCH 34/58] What the heck are these abysmal timeouts; closes #2223 --- app/Repositories/Wings/DaemonFileRepository.php | 3 +++ config/pterodactyl.php | 4 ++-- resources/scripts/api/server/files/compressFiles.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index 177f22afd..553e39d24 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -230,6 +230,9 @@ class DaemonFileRepository extends DaemonRepository 'root' => $root ?? '/', 'files' => $files, ], + // Wait for up to 15 minutes for the archive to be completed when calling this endpoint + // since it will likely take quite awhile for large directories. + 'timeout' => 60 * 15, ] ); } catch (TransferException $exception) { diff --git a/config/pterodactyl.php b/config/pterodactyl.php index 70014bc0a..b37790cbc 100644 --- a/config/pterodactyl.php +++ b/config/pterodactyl.php @@ -85,8 +85,8 @@ return [ | Configure the timeout to be used for Guzzle connections here. */ 'guzzle' => [ - 'timeout' => env('GUZZLE_TIMEOUT', 5), - 'connect_timeout' => env('GUZZLE_CONNECT_TIMEOUT', 3), + 'timeout' => env('GUZZLE_TIMEOUT', 30), + 'connect_timeout' => env('GUZZLE_CONNECT_TIMEOUT', 10), ], /* diff --git a/resources/scripts/api/server/files/compressFiles.ts b/resources/scripts/api/server/files/compressFiles.ts index 0554c7fd9..4204f0884 100644 --- a/resources/scripts/api/server/files/compressFiles.ts +++ b/resources/scripts/api/server/files/compressFiles.ts @@ -4,8 +4,8 @@ import { rawDataToFileObject } from '@/api/transformers'; export default async (uuid: string, directory: string, files: string[]): Promise => { const { data } = await http.post(`/api/client/servers/${uuid}/files/compress`, { root: directory, files }, { - timeout: 300000, - timeoutErrorMessage: 'It looks like this archive is taking a long time to generate. It will appear when completed.', + timeout: 60000, + timeoutErrorMessage: 'It looks like this archive is taking a long time to generate. It will appear once completed.', }); return rawDataToFileObject(data); From 14c587eabea5097f9ab5e31530998ed6769f938c Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Thu, 6 Aug 2020 20:33:17 -0700 Subject: [PATCH 35/58] Correctly inject new directory into file manager --- .../server/files/NewDirectoryButton.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/resources/scripts/components/server/files/NewDirectoryButton.tsx b/resources/scripts/components/server/files/NewDirectoryButton.tsx index 27cfb15f7..0a6a2b07f 100644 --- a/resources/scripts/components/server/files/NewDirectoryButton.tsx +++ b/resources/scripts/components/server/files/NewDirectoryButton.tsx @@ -8,11 +8,10 @@ import { object, string } from 'yup'; import createDirectory from '@/api/server/files/createDirectory'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; -import { mutate } from 'swr'; import useServer from '@/plugins/useServer'; import { FileObject } from '@/api/server/files/loadDirectory'; -import { useLocation } from 'react-router'; import useFlash from '@/plugins/useFlash'; +import useFileManagerSwr from '@/plugins/useFileManagerSwr'; interface Values { directoryName: string; @@ -38,20 +37,16 @@ const generateDirectoryData = (name: string): FileObject => ({ export default () => { const { uuid } = useServer(); - const { hash } = useLocation(); const { clearAndAddHttpError } = useFlash(); const [ visible, setVisible ] = useState(false); + + const { mutate } = useFileManagerSwr(); const directory = ServerContext.useStoreState(state => state.files.directory); const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers) => { createDirectory(uuid, directory, directoryName) - .then(() => { - mutate( - `${uuid}:files:${hash}`, - (data: FileObject[]) => [ ...data, generateDirectoryData(directoryName) ], - ); - setVisible(false); - }) + .then(() => mutate(data => [ ...data, generateDirectoryData(directoryName) ], false)) + .then(() => setVisible(false)) .catch(error => { console.error(error); setSubmitting(false); @@ -78,6 +73,7 @@ export default () => { >
Date: Wed, 12 Aug 2020 21:25:14 -0700 Subject: [PATCH 36/58] The first of our lovely sponsors --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 53c62f2b1..02899d449 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,17 @@ What more are you waiting for? Make game servers a first class citizen on your p ![Image](https://cdn.pterodactyl.io/site-assets/mockup-macbook-grey.png) +## Sponsors +I would like to extend my sincere thanks to the following sponsors for funding Pterodactyl's developement. [Interested +in becoming a sponsor?](https://github.com/sponsors/DaneEveritt) + +#### [BloomVPS](https://bloomvps.com) +> Ditch your overloaded server and see what dedicated Ryzen CPUs can do for your Minecraft community. + +#### [VersatileNode](https://versatilenode.com/) +> Looking to host a minecraft server, vps, or a website? VersatileNode is one of the most affordable hosting providers +to provide quality yet cheap services with incredible support. + ## Support & Documentation Support for using Pterodactyl can be found on our [Documentation Website](https://pterodactyl.io/project/introduction.html), [Guides Website](https://pterodactyl.io/community/about.html), or via our [Discord Chat](https://discord.gg/QRDZvVm). From c0f7c9bbf3ae3288fe77d1ee7603384caac993de Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Thu, 13 Aug 2020 20:29:46 -0700 Subject: [PATCH 37/58] Update README.md --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 02899d449..d788fb7c0 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,21 @@ I would like to extend my sincere thanks to the following sponsors for funding P in becoming a sponsor?](https://github.com/sponsors/DaneEveritt) #### [BloomVPS](https://bloomvps.com) -> Ditch your overloaded server and see what dedicated Ryzen CPUs can do for your Minecraft community. +> BloomVPS offers dedicated core VPS and Minecraft hosting with Ryzen 9 processors. With owned-hardware, we offer truly +> unbeatable prices on high-performance hosting. #### [VersatileNode](https://versatilenode.com/) > Looking to host a minecraft server, vps, or a website? VersatileNode is one of the most affordable hosting providers -to provide quality yet cheap services with incredible support. +> to provide quality yet cheap services with incredible support. + +#### [MineStrator](https://minestrator.com/) +> Looking for a French highend hosting company for you minecraft server? More than 14,000 members on our discord +> trust us. + +#### [DedicatedMC](https://dedicatedmc.io/) +> DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance +> and giving you the best performance money can buy. + ## Support & Documentation Support for using Pterodactyl can be found on our [Documentation Website](https://pterodactyl.io/project/introduction.html), [Guides Website](https://pterodactyl.io/community/about.html), or via our [Discord Chat](https://discord.gg/QRDZvVm). From 231ff0386c946915bcec332c243f31a66187ae07 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Thu, 13 Aug 2020 20:47:16 -0700 Subject: [PATCH 38/58] Fix kill button not showing up when restarting --- .../scripts/components/server/StopOrKillButton.tsx | 2 +- resources/scripts/components/server/events.ts | 10 ++++++++++ resources/scripts/plugins/Websocket.ts | 7 ------- 3 files changed, 11 insertions(+), 8 deletions(-) create mode 100644 resources/scripts/components/server/events.ts diff --git a/resources/scripts/components/server/StopOrKillButton.tsx b/resources/scripts/components/server/StopOrKillButton.tsx index b9daed85b..fc8490655 100644 --- a/resources/scripts/components/server/StopOrKillButton.tsx +++ b/resources/scripts/components/server/StopOrKillButton.tsx @@ -8,7 +8,7 @@ const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void const status = ServerContext.useStoreState(state => state.status.value); useEffect(() => { - setClicked(state => [ 'stopping' ].indexOf(status) < 0 ? false : state); + setClicked(status === 'stopping'); }, [ status ]); return ( diff --git a/resources/scripts/components/server/events.ts b/resources/scripts/components/server/events.ts new file mode 100644 index 000000000..4f4c35bde --- /dev/null +++ b/resources/scripts/components/server/events.ts @@ -0,0 +1,10 @@ +export enum SocketEvent { + DAEMON_MESSAGE = 'daemon message', + INSTALL_OUTPUT = 'install output', + INSTALL_STARTED = 'install started', + INSTALL_COMPLETED = 'install completed', + CONSOLE_OUTPUT = 'console output', + STATUS = 'status', + STATS = 'stats', + BACKUP_COMPLETED = 'backup completed', +} diff --git a/resources/scripts/plugins/Websocket.ts b/resources/scripts/plugins/Websocket.ts index 0aa13769d..0f8150dcd 100644 --- a/resources/scripts/plugins/Websocket.ts +++ b/resources/scripts/plugins/Websocket.ts @@ -1,13 +1,6 @@ import Sockette from 'sockette'; import { EventEmitter } from 'events'; -export const SOCKET_EVENTS = [ - 'SOCKET_OPEN', - 'SOCKET_RECONNECT', - 'SOCKET_CLOSE', - 'SOCKET_ERROR', -]; - export class Websocket extends EventEmitter { // Timer instance for this socket. private timer: any = null; From 800b475ec5c6721134200808cb168b2ddff5785f Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Thu, 13 Aug 2020 21:21:08 -0700 Subject: [PATCH 39/58] Respond with the actual error from wings if available; closes #2224 --- .../Connection/DaemonConnectionException.php | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/app/Exceptions/Http/Connection/DaemonConnectionException.php b/app/Exceptions/Http/Connection/DaemonConnectionException.php index 2eb7e93ca..e6765b8a6 100644 --- a/app/Exceptions/Http/Connection/DaemonConnectionException.php +++ b/app/Exceptions/Http/Connection/DaemonConnectionException.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Exceptions\Http\Connection; +use Illuminate\Support\Arr; use Illuminate\Http\Response; use GuzzleHttp\Exception\GuzzleException; use Pterodactyl\Exceptions\DisplayException; @@ -28,12 +29,28 @@ class DaemonConnectionException extends DisplayException $response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null; if ($useStatusCode) { - $this->statusCode = is_null($response) ? 500 : $response->getStatusCode(); + $this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode(); } - parent::__construct(trans('admin/server.exceptions.daemon_exception', [ + $message = trans('admin/server.exceptions.daemon_exception', [ 'code' => is_null($response) ? 'E_CONN_REFUSED' : $response->getStatusCode(), - ]), $previous, DisplayException::LEVEL_WARNING); + ]); + + // Attempt to pull the actual error message off the response and return that if it is not + // a 500 level error. + if ($this->statusCode < 500 && ! is_null($response)) { + $body = $response->getBody(); + if (is_string($body) || (is_object($body) && method_exists($body, '__toString'))) { + $body = json_decode(is_string($body) ? $body : $body->__toString(), true); + $message = "[Wings Error]: " . Arr::get($body, 'error', $message); + } + } + + $level = $this->statusCode >= 500 && $this->statusCode !== 504 + ? DisplayException::LEVEL_ERROR + : DisplayException::LEVEL_WARNING; + + parent::__construct($message, $previous, $level); } /** From 1ced8da735fb95b6cfe1b094c3c900573d4cfa2d Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Mon, 17 Aug 2020 19:04:23 -0700 Subject: [PATCH 40/58] Update README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index d788fb7c0..eae7dfc65 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ in becoming a sponsor?](https://github.com/sponsors/DaneEveritt) > DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance > and giving you the best performance money can buy. +### [Skynode](https://skynode.com) +> Skynode provides blazing fast game servers along with a top notch user experience. Whatever our clients are looking +> for, we're able to provide it! ## Support & Documentation Support for using Pterodactyl can be found on our [Documentation Website](https://pterodactyl.io/project/introduction.html), [Guides Website](https://pterodactyl.io/community/about.html), or via our [Discord Chat](https://discord.gg/QRDZvVm). From a6cc53793d45322e0bd966382ffdc456e09c58c0 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Mon, 17 Aug 2020 19:05:01 -0700 Subject: [PATCH 41/58] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eae7dfc65..84d428ee0 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ in becoming a sponsor?](https://github.com/sponsors/DaneEveritt) > DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance > and giving you the best performance money can buy. -### [Skynode](https://skynode.com) +#### [Skynode](https://www.skynode.pro/) > Skynode provides blazing fast game servers along with a top notch user experience. Whatever our clients are looking > for, we're able to provide it! From d41b86f0ea77eafbaff8b79f663497f8e02240cc Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Mon, 17 Aug 2020 19:48:51 -0700 Subject: [PATCH 42/58] Correctly pass along allowed IPs for client API keys, closes #2244 --- .../Requests/Api/Client/Account/StoreApiKeyRequest.php | 10 ++++++++++ .../components/dashboard/forms/CreateApiKeyForm.tsx | 10 +++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php b/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php index 00197388a..a82db1ec0 100644 --- a/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php +++ b/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php @@ -17,4 +17,14 @@ class StoreApiKeyRequest extends ClientApiRequest 'allowed_ips.*' => 'ip', ]; } + + /** + * @return array|string[] + */ + public function messages() + { + return [ + 'allowed_ips.*' => 'All of the IP addresses entered must be valid IPv4 addresses.', + ]; + } } diff --git a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx index 4e8fae1d5..3e52e68ae 100644 --- a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx +++ b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx @@ -12,12 +12,15 @@ import { ApiKey } from '@/api/account/getApiKeys'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import Input, { Textarea } from '@/components/elements/Input'; +import styled from 'styled-components/macro'; interface Values { description: string; allowedIps: string; } +const CustomTextarea = styled(Textarea)`${tw`h-32`}`; + export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => { const [ apiKey, setApiKey ] = useState(''); const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); @@ -66,10 +69,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => { void }) => { name={'allowedIps'} description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'} > - +
From c28cba92e2192b1be8c18f8923fcb95225c8b02c Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Mon, 17 Aug 2020 21:35:11 -0700 Subject: [PATCH 43/58] Make modals programatically controllable via a HOC This allows entire components to be unmounted when the modal is hidden without affecting the fade in/out of the modal itself. This also makes it easier to programatically dismiss a modal without having to copy the visibility all over the place, and makes working with props much simpler in those modal components --- .../dashboard/AccountApiContainer.tsx | 6 +- .../components/dashboard/ApiKeyModal.tsx | 38 +++++++++ .../dashboard/forms/CreateApiKeyForm.tsx | 28 ++----- .../components/elements/ConfirmationModal.tsx | 52 ++++++------ .../scripts/components/elements/Fade.tsx | 6 +- .../scripts/components/elements/Modal.tsx | 12 ++- .../server/backups/BackupContextMenu.tsx | 6 +- .../server/files/MassActionsBar.tsx | 2 +- .../server/schedules/DeleteScheduleButton.tsx | 6 +- .../server/schedules/ScheduleTaskRow.tsx | 2 +- .../server/settings/ReinstallServerBox.tsx | 4 +- .../server/users/RemoveSubuserButton.tsx | 4 +- resources/scripts/context/ModalContext.ts | 15 ++++ resources/scripts/hoc/asModal.tsx | 81 +++++++++++++++++++ 14 files changed, 192 insertions(+), 70 deletions(-) create mode 100644 resources/scripts/components/dashboard/ApiKeyModal.tsx create mode 100644 resources/scripts/context/ModalContext.ts create mode 100644 resources/scripts/hoc/asModal.tsx diff --git a/resources/scripts/components/dashboard/AccountApiContainer.tsx b/resources/scripts/components/dashboard/AccountApiContainer.tsx index c80a51a20..304fe5630 100644 --- a/resources/scripts/components/dashboard/AccountApiContainer.tsx +++ b/resources/scripts/components/dashboard/AccountApiContainer.tsx @@ -61,21 +61,19 @@ export default () => { - {deleteIdentifier && { doDeletion(deleteIdentifier); setDeleteIdentifier(''); }} - onDismissed={() => setDeleteIdentifier('')} + onModalDismissed={() => setDeleteIdentifier('')} > Are you sure you wish to delete this API key? All requests using it will immediately be invalidated and will fail. - } { keys.length === 0 ?

diff --git a/resources/scripts/components/dashboard/ApiKeyModal.tsx b/resources/scripts/components/dashboard/ApiKeyModal.tsx new file mode 100644 index 000000000..db511edb8 --- /dev/null +++ b/resources/scripts/components/dashboard/ApiKeyModal.tsx @@ -0,0 +1,38 @@ +import React, { useContext } from 'react'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import asModal from '@/hoc/asModal'; +import ModalContext from '@/context/ModalContext'; + +interface Props { + apiKey: string; +} + +const ApiKeyModal = ({ apiKey }: Props) => { + const { dismiss } = useContext(ModalContext); + + return ( + <> +

Your API Key

+

+ The API key you have requested is shown below. Please store this in a safe location, it will not be + shown again. +

+
+                {apiKey}
+            
+
+ +
+ + ); +}; + +ApiKeyModal.displayName = 'ApiKeyModal'; + +export default asModal({ + closeOnEscape: false, + closeOnBackground: false, +})(ApiKeyModal); diff --git a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx index 3e52e68ae..9022ae6c8 100644 --- a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx +++ b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { Field, Form, Formik, FormikHelpers } from 'formik'; import { object, string } from 'yup'; import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; -import Modal from '@/components/elements/Modal'; import createApiKey from '@/api/account/createApiKey'; import { Actions, useStoreActions } from 'easy-peasy'; import { ApplicationStore } from '@/state'; @@ -13,6 +12,7 @@ import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import Input, { Textarea } from '@/components/elements/Input'; import styled from 'styled-components/macro'; +import ApiKeyModal from '@/components/dashboard/ApiKeyModal'; interface Values { description: string; @@ -44,29 +44,11 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => { return ( <> - 0} - onDismissed={() => setApiKey('')} - closeOnEscape={false} - closeOnBackground={false} - > -

Your API Key

-

- The API key you have requested is shown below. Please store this in a safe location, it will not be - shown again. -

-
-                    {apiKey}
-                
-
- -
-
+ onModalDismissed={() => setApiKey('')} + apiKey={apiKey} + /> void; showSpinnerOverlay?: boolean; -} & RequiredModalProps; +}; -const ConfirmationModal = ({ title, appear, children, visible, buttonText, onConfirmed, showSpinnerOverlay, onDismissed }: Props) => ( - onDismissed()} - > -

{title}

-

{children}

-
- - -
-
-); +const ConfirmationModal = ({ title, children, buttonText, onConfirmed, showSpinnerOverlay }: Props) => { + const { dismiss, toggleSpinner } = useContext(ModalContext); -export default ConfirmationModal; + useEffect(() => { + toggleSpinner(showSpinnerOverlay); + }, [ showSpinnerOverlay ]); + + return ( + <> +

{title}

+

{children}

+
+ + +
+ + ); +}; + +ConfirmationModal.displayName = 'ConfirmationModal'; + +export default asModal()(ConfirmationModal); diff --git a/resources/scripts/components/elements/Fade.tsx b/resources/scripts/components/elements/Fade.tsx index 62850283e..2b9c3efa8 100644 --- a/resources/scripts/components/elements/Fade.tsx +++ b/resources/scripts/components/elements/Fade.tsx @@ -8,14 +8,14 @@ interface Props extends Omit { } const Container = styled.div<{ timeout: number }>` - .fade-enter, .fade-exit { + .fade-enter, .fade-exit, .fade-appear { will-change: opacity; } - .fade-enter { + .fade-enter, .fade-appear { ${tw`opacity-0`}; - &.fade-enter-active { + &.fade-enter-active, &.fade-appear-active { ${tw`opacity-100 transition-opacity ease-in`}; transition-duration: ${props => props.timeout}ms; } diff --git a/resources/scripts/components/elements/Modal.tsx b/resources/scripts/components/elements/Modal.tsx index f242fbacc..de0be05e6 100644 --- a/resources/scripts/components/elements/Modal.tsx +++ b/resources/scripts/components/elements/Modal.tsx @@ -13,7 +13,7 @@ export interface RequiredModalProps { top?: boolean; } -interface Props extends RequiredModalProps { +export interface ModalProps extends RequiredModalProps { dismissable?: boolean; closeOnEscape?: boolean; closeOnBackground?: boolean; @@ -40,7 +40,7 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>` } `; -const Modal: React.FC = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => { +const Modal: React.FC = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => { const [ render, setRender ] = useState(visible); const isDismissable = useMemo(() => { @@ -62,7 +62,13 @@ const Modal: React.FC = ({ visible, appear, dismissable, showSpinnerOverl }, [ render ]); return ( - + onDismissed()} + > { if (isDismissable && closeOnBackground) { diff --git a/resources/scripts/components/server/backups/BackupContextMenu.tsx b/resources/scripts/components/server/backups/BackupContextMenu.tsx index 54a45b9d3..d9c74150d 100644 --- a/resources/scripts/components/server/backups/BackupContextMenu.tsx +++ b/resources/scripts/components/server/backups/BackupContextMenu.tsx @@ -65,18 +65,16 @@ export default ({ backup }: Props) => { checksum={backup.sha256Hash} /> } - {deleteVisible && doDeletion()} - visible={deleteVisible} - onDismissed={() => setDeleteVisible(false)} + onModalDismissed={() => setDeleteVisible(false)} > Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot be recovered once deleted. - } ( diff --git a/resources/scripts/components/server/files/MassActionsBar.tsx b/resources/scripts/components/server/files/MassActionsBar.tsx index 6df43ecca..40067d039 100644 --- a/resources/scripts/components/server/files/MassActionsBar.tsx +++ b/resources/scripts/components/server/files/MassActionsBar.tsx @@ -72,7 +72,7 @@ const MassActionsBar = () => { title={'Delete these files?'} buttonText={'Yes, Delete Files'} onConfirmed={onClickConfirmDeletion} - onDismissed={() => setShowConfirm(false)} + onModalDismissed={() => setShowConfirm(false)} > Deleting files is a permanent operation, you cannot undo this action. diff --git a/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx b/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx index 26d86652d..198060388 100644 --- a/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx +++ b/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx @@ -39,12 +39,12 @@ export default ({ scheduleId, onDeleted }: Props) => { return ( <> setVisible(false)} + showSpinnerOverlay={isLoading} + onModalDismissed={() => setVisible(false)} > Are you sure you want to delete this schedule? All tasks will be removed and any running processes will be terminated. diff --git a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx index fb1136f73..b14a24ea3 100644 --- a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx +++ b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx @@ -69,7 +69,7 @@ export default ({ schedule, task }: Props) => { buttonText={'Delete Task'} onConfirmed={onConfirmDeletion} visible={visible} - onDismissed={() => setVisible(false)} + onModalDismissed={() => setVisible(false)} > Are you sure you want to delete this task? This action cannot be undone. diff --git a/resources/scripts/components/server/settings/ReinstallServerBox.tsx b/resources/scripts/components/server/settings/ReinstallServerBox.tsx index 1b7b44de7..0c1ce8ec7 100644 --- a/resources/scripts/components/server/settings/ReinstallServerBox.tsx +++ b/resources/scripts/components/server/settings/ReinstallServerBox.tsx @@ -46,10 +46,10 @@ export default () => { reinstall()} + onConfirmed={reinstall} showSpinnerOverlay={isSubmitting} visible={modalVisible} - onDismissed={() => setModalVisible(false)} + onModalDismissed={() => setModalVisible(false)} > Your server will be stopped and some files may be deleted or modified during this process, are you sure you wish to continue? diff --git a/resources/scripts/components/server/users/RemoveSubuserButton.tsx b/resources/scripts/components/server/users/RemoveSubuserButton.tsx index 976c64170..f6481dfc4 100644 --- a/resources/scripts/components/server/users/RemoveSubuserButton.tsx +++ b/resources/scripts/components/server/users/RemoveSubuserButton.tsx @@ -35,19 +35,17 @@ export default ({ subuser }: { subuser: Subuser }) => { return ( <> - {showConfirmation && doDeletion()} - onDismissed={() => setShowConfirmation(false)} + onModalDismissed={() => setShowConfirmation(false)} > Are you sure you wish to remove this subuser? They will have all access to this server revoked immediately. - }
} {showSpinnerOverlay && -
- -
+ +
+ +
+
}
{children} diff --git a/resources/scripts/hoc/asModal.tsx b/resources/scripts/hoc/asModal.tsx index 2311b1441..7db437c14 100644 --- a/resources/scripts/hoc/asModal.tsx +++ b/resources/scripts/hoc/asModal.tsx @@ -1,6 +1,7 @@ import React from 'react'; import Modal, { ModalProps } from '@/components/elements/Modal'; import ModalContext from '@/context/ModalContext'; +import isEqual from 'react-fast-compare'; export interface AsModalProps { visible: boolean; @@ -12,26 +13,34 @@ type ExtendedModalProps = Omit interface State { render: boolean; visible: boolean; - showSpinnerOverlay: boolean; + modalProps: ExtendedModalProps | undefined; } -function asModal (modalProps?: ExtendedModalProps) { - // eslint-disable-next-line @typescript-eslint/ban-types - return function (Component: React.ComponentType) { - return class extends React.PureComponent { +type ExtendedComponentType = (C: React.ComponentType) => React.ComponentType; + +// eslint-disable-next-line @typescript-eslint/ban-types +function asModal

(modalProps?: ExtendedModalProps | ((props: P) => ExtendedModalProps)): ExtendedComponentType

{ + return function (Component) { + return class extends React.PureComponent

{ static displayName = `asModal(${Component.displayName})`; - constructor (props: T & AsModalProps) { + constructor (props: P & AsModalProps) { super(props); this.state = { render: props.visible, visible: props.visible, - showSpinnerOverlay: modalProps?.showSpinnerOverlay || false, + modalProps: typeof modalProps === 'function' ? modalProps(this.props) : modalProps, }; } - componentDidUpdate (prevProps: Readonly) { + componentDidUpdate (prevProps: Readonly

) { + const mapped = typeof modalProps === 'function' ? modalProps(this.props) : modalProps; + if (!isEqual(this.state.modalProps, mapped)) { + // noinspection JSPotentiallyInvalidUsageOfThis + this.setState({ modalProps: mapped }); + } + if (prevProps.visible && !this.props.visible) { // noinspection JSPotentiallyInvalidUsageOfThis this.setState({ visible: false }); @@ -43,7 +52,12 @@ function asModal (modalProps?: ExtendedModalProps) { dismiss = () => this.setState({ visible: false }); - toggleSpinner = (value?: boolean) => this.setState({ showSpinnerOverlay: value || false }); + toggleSpinner = (value?: boolean) => this.setState(s => ({ + modalProps: { + ...s.modalProps, + showSpinnerOverlay: value || false, + }, + })); render () { return ( @@ -58,13 +72,12 @@ function asModal (modalProps?: ExtendedModalProps) { this.setState({ render: false }, () => { if (typeof this.props.onModalDismissed === 'function') { this.props.onModalDismissed(); } })} - {...modalProps} + {...this.state.modalProps} > From 57bb652d8190a34da80144dd509e55f868871726 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Tue, 18 Aug 2020 20:16:13 -0700 Subject: [PATCH 45/58] Whoops, don't always show this modal --- .../scripts/components/server/users/RemoveSubuserButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/scripts/components/server/users/RemoveSubuserButton.tsx b/resources/scripts/components/server/users/RemoveSubuserButton.tsx index f6481dfc4..a7fb4ce67 100644 --- a/resources/scripts/components/server/users/RemoveSubuserButton.tsx +++ b/resources/scripts/components/server/users/RemoveSubuserButton.tsx @@ -38,7 +38,7 @@ export default ({ subuser }: { subuser: Subuser }) => { doDeletion()} onModalDismissed={() => setShowConfirmation(false)} From 61e977133318a1888d79d2048841660650f250d1 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 19 Aug 2020 20:21:12 -0700 Subject: [PATCH 46/58] Code cleanup for subuser API endpoints; closes #2247 --- app/Exceptions/Handler.php | 7 ++ .../Api/Client/Servers/SubuserController.php | 34 ++++++-- .../Client/Server/SubuserBelongsToServer.php | 37 +++++++++ .../Client/SubstituteClientApiBindings.php | 5 ++ .../Servers/Subusers/SubuserRequest.php | 78 +++---------------- app/Models/Server.php | 2 +- .../Eloquent/SubuserRepository.php | 24 ------ .../Servers/GetUserPermissionsService.php | 2 +- routes/api-client.php | 9 ++- 9 files changed, 94 insertions(+), 104 deletions(-) create mode 100644 app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 50ac1a960..d278ce0bc 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -213,6 +213,13 @@ class Handler extends ExceptionHandler 'detail' => 'An error was encountered while processing this request.', ]; + if ($exception instanceof ModelNotFoundException || $exception->getPrevious() instanceof ModelNotFoundException) { + // Show a nicer error message compared to the standard "No query results for model" + // response that is normally returned. If we are in debug mode this will get overwritten + // with a more specific error message to help narrow down things. + $error['detail'] = 'The requested resource could not be found on the server.'; + } + if (config('app.debug')) { $error = array_merge($error, [ 'detail' => $exception->getMessage(), diff --git a/app/Http/Controllers/Api/Client/Servers/SubuserController.php b/app/Http/Controllers/Api/Client/Servers/SubuserController.php index da6fee428..d8bdcc40a 100644 --- a/app/Http/Controllers/Api/Client/Servers/SubuserController.php +++ b/app/Http/Controllers/Api/Client/Servers/SubuserController.php @@ -3,7 +3,9 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Illuminate\Http\Request; +use Pterodactyl\Models\User; use Pterodactyl\Models\Server; +use Pterodactyl\Models\Subuser; use Illuminate\Http\JsonResponse; use Pterodactyl\Models\Permission; use Pterodactyl\Repositories\Eloquent\SubuserRepository; @@ -57,6 +59,21 @@ class SubuserController extends ClientApiController ->toArray(); } + /** + * Returns a single subuser associated with this server instance. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\GetSubuserRequest $request + * @return array + */ + public function view(GetSubuserRequest $request) + { + $subuser = $request->attributes->get('subuser'); + + return $this->fractal->item($subuser) + ->transformWith($this->getTransformer(SubuserTransformer::class)) + ->toArray(); + } + /** * Create a new subuser for the given server. * @@ -84,15 +101,16 @@ class SubuserController extends ClientApiController * Update a given subuser in the system for the server. * * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request - * @param \Pterodactyl\Models\Server $server * @return array * * @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ - public function update(UpdateSubuserRequest $request, Server $server): array + public function update(UpdateSubuserRequest $request): array { - $subuser = $request->endpointSubuser(); + /** @var \Pterodactyl\Models\Subuser $subuser */ + $subuser = $request->attributes->get('subuser'); + $this->repository->update($subuser->id, [ 'permissions' => $this->getDefaultPermissions($request), ]); @@ -106,14 +124,16 @@ class SubuserController extends ClientApiController * Removes a subusers from a server's assignment. * * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request - * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse */ - public function delete(DeleteSubuserRequest $request, Server $server) + public function delete(DeleteSubuserRequest $request) { - $this->repository->delete($request->endpointSubuser()->id); + /** @var \Pterodactyl\Models\Subuser $subuser */ + $subuser = $request->attributes->get('subuser'); - return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT); + $this->repository->delete($subuser->id); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } /** diff --git a/app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php b/app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php new file mode 100644 index 000000000..894d6b000 --- /dev/null +++ b/app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php @@ -0,0 +1,37 @@ +route()->parameter('server'); + /** @var \Pterodactyl\Models\User $user */ + $user = $request->route()->parameter('user'); + + // Don't do anything if there isn't a user present in the request. + if (is_null($user)) { + return $next($request); + } + + $request->attributes->set('subuser', $server->subusers()->where('user_id', $user->id)->firstOrFail()); + + return $next($request); + } +} diff --git a/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php b/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php index 0bd40eee5..77879c97f 100644 --- a/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php +++ b/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Http\Middleware\Api\Client; use Closure; +use Pterodactyl\Models\User; use Pterodactyl\Models\Backup; use Pterodactyl\Models\Database; use Illuminate\Container\Container; @@ -52,6 +53,10 @@ class SubstituteClientApiBindings extends ApiSubstituteBindings return Backup::query()->where('uuid', $value)->firstOrFail(); }); + $this->router->model('user', User::class, function ($value) { + return User::query()->where('uuid', $value)->firstOrFail(); + }); + return parent::handle($request, $next); } } diff --git a/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php b/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php index e43b7178e..98d0d9643 100644 --- a/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php @@ -3,12 +3,10 @@ namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers; use Illuminate\Http\Request; -use Pterodactyl\Models\Server; +use Pterodactyl\Models\User; use Pterodactyl\Exceptions\Http\HttpForbiddenException; -use Pterodactyl\Repositories\Eloquent\SubuserRepository; use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest; -use Pterodactyl\Exceptions\Repository\RecordNotFoundException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Pterodactyl\Services\Servers\GetUserPermissionsService; abstract class SubuserRequest extends ClientApiRequest { @@ -30,10 +28,10 @@ abstract class SubuserRequest extends ClientApiRequest return false; } - // If there is a subuser present in the URL, validate that it is not the same as the - // current request user. You're not allowed to modify yourself. - if ($this->route()->hasParameter('subuser')) { - if ($this->endpointSubuser()->user_id === $this->user()->id) { + $user = $this->route()->parameter('user'); + // Don't allow a user to edit themselves on the server. + if ($user instanceof User) { + if ($user->uuid === $this->user()->uuid) { return false; } } @@ -71,68 +69,14 @@ abstract class SubuserRequest extends ClientApiRequest // Otherwise, get the current subuser's permission set, and ensure that the // permissions they are trying to assign are not _more_ than the ones they // already have. - if (count(array_diff($permissions, $this->currentUserPermissions())) > 0) { + /** @var \Pterodactyl\Models\Subuser|null $subuser */ + /** @var \Pterodactyl\Services\Servers\GetUserPermissionsService $service */ + $service = $this->container->make(GetUserPermissionsService::class); + + if (count(array_diff($permissions, $service->handle($server, $user))) > 0) { throw new HttpForbiddenException( 'Cannot assign permissions to a subuser that your account does not actively possess.' ); } } - - /** - * Returns the currently authenticated user's permissions. - * - * @return array - * - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function currentUserPermissions(): array - { - /** @var \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository */ - $repository = $this->container->make(SubuserRepository::class); - - /* @var \Pterodactyl\Models\Subuser $model */ - try { - $model = $repository->findFirstWhere([ - ['server_id', $this->route()->parameter('server')->id], - ['user_id', $this->user()->id], - ]); - } catch (RecordNotFoundException $exception) { - return []; - } - - return $model->permissions; - } - - /** - * Return the subuser model for the given request which can then be validated. If - * required request parameters are missing a 404 error will be returned, otherwise - * a model exception will be returned if the model is not found. - * - * This returns the subuser based on the endpoint being hit, not the actual subuser - * for the account making the request. - * - * @return \Pterodactyl\Models\Subuser - * - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function endpointSubuser() - { - /** @var \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository */ - $repository = $this->container->make(SubuserRepository::class); - - $parameters = $this->route()->parameters(); - if ( - ! isset($parameters['server'], $parameters['server']) - || ! is_string($parameters['subuser']) - || ! $parameters['server'] instanceof Server - ) { - throw new NotFoundHttpException; - } - - return $this->model ?: $this->model = $repository->getUserForServer( - $parameters['server']->id, $parameters['subuser'] - ); - } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 8f15bfcf1..8894a4d60 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -38,7 +38,7 @@ use Znck\Eloquent\Traits\BelongsToThrough; * @property \Carbon\Carbon $updated_at * * @property \Pterodactyl\Models\User $user - * @property \Pterodactyl\Models\User[]|\Illuminate\Database\Eloquent\Collection $subusers + * @property \Pterodactyl\Models\Subuser[]|\Illuminate\Database\Eloquent\Collection $subusers * @property \Pterodactyl\Models\Allocation $allocation * @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations * @property \Pterodactyl\Models\Pack|null $pack diff --git a/app/Repositories/Eloquent/SubuserRepository.php b/app/Repositories/Eloquent/SubuserRepository.php index e00d825e7..c0fb930a6 100644 --- a/app/Repositories/Eloquent/SubuserRepository.php +++ b/app/Repositories/Eloquent/SubuserRepository.php @@ -18,30 +18,6 @@ class SubuserRepository extends EloquentRepository implements SubuserRepositoryI return Subuser::class; } - /** - * Returns a subuser model for the given user and server combination. If no record - * exists an exception will be thrown. - * - * @param int $server - * @param string $uuid - * @return \Pterodactyl\Models\Subuser - * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - */ - public function getUserForServer(int $server, string $uuid): Subuser - { - /** @var \Pterodactyl\Models\Subuser $model */ - $model = $this->getBuilder() - ->with('server', 'user') - ->select('subusers.*') - ->join('users', 'users.id', '=', 'subusers.user_id') - ->where('subusers.server_id', $server) - ->where('users.uuid', $uuid) - ->firstOrFail(); - - return $model; - } - /** * Return a subuser with the associated server relationship. * diff --git a/app/Services/Servers/GetUserPermissionsService.php b/app/Services/Servers/GetUserPermissionsService.php index 98dcf6c34..e0ea20373 100644 --- a/app/Services/Servers/GetUserPermissionsService.php +++ b/app/Services/Servers/GetUserPermissionsService.php @@ -30,7 +30,7 @@ class GetUserPermissionsService } /** @var \Pterodactyl\Models\Subuser|null $subuserPermissions */ - $subuserPermissions = $server->subusers->where('user_id', $user->id)->first(); + $subuserPermissions = $server->subusers()->where('user_id', $user->id)->first(); return $subuserPermissions ? $subuserPermissions->permissions : []; } diff --git a/routes/api-client.php b/routes/api-client.php index f92ba6ed9..c9ec16097 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -1,6 +1,7 @@ '/servers/{server}', 'middleware' => [AuthenticateServ Route::delete('/allocations/{allocation}', 'Servers\NetworkAllocationController@delete'); }); - Route::group(['prefix' => '/users'], function () { + Route::group(['prefix' => '/users', 'middleware' => [SubuserBelongsToServer::class]], function () { Route::get('/', 'Servers\SubuserController@index'); Route::post('/', 'Servers\SubuserController@store'); - Route::get('/{subuser}', 'Servers\SubuserController@view'); - Route::post('/{subuser}', 'Servers\SubuserController@update'); - Route::delete('/{subuser}', 'Servers\SubuserController@delete'); + Route::get('/{user}', 'Servers\SubuserController@view'); + Route::post('/{user}', 'Servers\SubuserController@update'); + Route::delete('/{user}', 'Servers\SubuserController@delete'); }); Route::group(['prefix' => '/backups'], function () { From 540cc82e3dc28e983fba3388cc942ef9d6217ee9 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Wed, 19 Aug 2020 20:38:51 -0700 Subject: [PATCH 47/58] Don't resolve database hosts; closes #2237 --- .../Client/Server/SubuserBelongsToServer.php | 1 - .../Admin/DatabaseHostFormRequest.php | 4 -- app/Models/DatabaseHost.php | 16 +++++- app/Rules/ResolvesToIPAddress.php | 49 +++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 app/Rules/ResolvesToIPAddress.php diff --git a/app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php b/app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php index 894d6b000..a80f6eefd 100644 --- a/app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php +++ b/app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php @@ -3,7 +3,6 @@ namespace Pterodactyl\Http\Middleware\Api\Client\Server; use Closure; -use Exception; use Illuminate\Http\Request; class SubuserBelongsToServer diff --git a/app/Http/Requests/Admin/DatabaseHostFormRequest.php b/app/Http/Requests/Admin/DatabaseHostFormRequest.php index 54d3bd0cc..c6b2468a7 100644 --- a/app/Http/Requests/Admin/DatabaseHostFormRequest.php +++ b/app/Http/Requests/Admin/DatabaseHostFormRequest.php @@ -29,10 +29,6 @@ class DatabaseHostFormRequest extends AdminFormRequest $this->merge(['node_id' => null]); } - $this->merge([ - 'host' => gethostbyname($this->input('host')), - ]); - return parent::getValidatorInstance(); } } diff --git a/app/Models/DatabaseHost.php b/app/Models/DatabaseHost.php index 6fafce2f0..d76fed494 100644 --- a/app/Models/DatabaseHost.php +++ b/app/Models/DatabaseHost.php @@ -2,6 +2,8 @@ namespace Pterodactyl\Models; +use Pterodactyl\Rules\ResolvesToIPAddress; + class DatabaseHost extends Model { /** @@ -51,13 +53,25 @@ class DatabaseHost extends Model */ public static $validationRules = [ 'name' => 'required|string|max:255', - 'host' => 'required|unique:database_hosts,host', + 'host' => 'required|string', 'port' => 'required|numeric|between:1,65535', 'username' => 'required|string|max:32', 'password' => 'nullable|string', 'node_id' => 'sometimes|nullable|integer|exists:nodes,id', ]; + /** + * @return array + */ + public static function getRules() + { + $rules = parent::getRules(); + + $rules['host'] = array_merge($rules['host'], [ new ResolvesToIPAddress() ]); + + return $rules; + } + /** * Gets the node associated with a database host. * diff --git a/app/Rules/ResolvesToIPAddress.php b/app/Rules/ResolvesToIPAddress.php new file mode 100644 index 000000000..e1421b52c --- /dev/null +++ b/app/Rules/ResolvesToIPAddress.php @@ -0,0 +1,49 @@ + Date: Wed, 19 Aug 2020 21:11:29 -0700 Subject: [PATCH 48/58] Move the file selector out of the editor itself; closes #2147 --- .../scripts/components/elements/AceEditor.tsx | 46 ++++++------------- .../server/files/FileEditContainer.tsx | 30 +++++++----- resources/scripts/modes.d.ts | 3 ++ resources/scripts/modes.js | 1 + 4 files changed, 36 insertions(+), 44 deletions(-) create mode 100644 resources/scripts/modes.d.ts diff --git a/resources/scripts/components/elements/AceEditor.tsx b/resources/scripts/components/elements/AceEditor.tsx index 0b4ebca95..fbea88b8f 100644 --- a/resources/scripts/components/elements/AceEditor.tsx +++ b/resources/scripts/components/elements/AceEditor.tsx @@ -2,8 +2,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import ace, { Editor } from 'brace'; import styled from 'styled-components/macro'; import tw from 'twin.macro'; -import Select from '@/components/elements/Select'; -// @ts-ignore import modes from '@/modes'; // @ts-ignore @@ -21,42 +19,38 @@ const EditorContainer = styled.div` `; Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`)); +const modelist = ace.acequire('ace/ext/modelist'); export interface Props { style?: React.CSSProperties; initialContent?: string; - initialModePath?: string; + mode: string; + filename?: string; + onModeChanged: (mode: string) => void; fetchContent: (callback: () => Promise) => void; onContentSaved: (content: string) => void; } -export default ({ style, initialContent, initialModePath, fetchContent, onContentSaved }: Props) => { - const [ mode, setMode ] = useState('ace/mode/plain_text'); - +export default ({ style, initialContent, filename, mode, fetchContent, onContentSaved, onModeChanged }: Props) => { const [ editor, setEditor ] = useState(); const ref = useCallback(node => { - if (node) { - setEditor(ace.edit('editor')); - } + if (node) setEditor(ace.edit('editor')); }, []); useEffect(() => { - editor && editor.session.setMode(mode); + if (modelist && filename) { + onModeChanged(modelist.getModeForPath(filename).mode.replace(/^ace\/mode\//, '')); + } + }, [ filename ]); + + useEffect(() => { + editor && editor.session.setMode(`ace/mode/${mode}`); }, [ editor, mode ]); useEffect(() => { editor && editor.session.setValue(initialContent || ''); }, [ editor, initialContent ]); - useEffect(() => { - if (initialModePath) { - const modelist = ace.acequire('ace/ext/modelist'); - if (modelist) { - setMode(modelist.getModeForPath(initialModePath).mode); - } - } - }, [ initialModePath ]); - useEffect(() => { if (!editor) { fetchContent(() => Promise.reject(new Error('no editor session has been configured'))); @@ -85,20 +79,6 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten return (

-
-
- -
-
); }; diff --git a/resources/scripts/components/server/files/FileEditContainer.tsx b/resources/scripts/components/server/files/FileEditContainer.tsx index fdde9bb1b..4106ad53d 100644 --- a/resources/scripts/components/server/files/FileEditContainer.tsx +++ b/resources/scripts/components/server/files/FileEditContainer.tsx @@ -1,8 +1,5 @@ import React, { lazy, useEffect, useState } from 'react'; -import { ServerContext } from '@/state/server'; import getFileContents from '@/api/server/files/getFileContents'; -import { Actions, useStoreActions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; import { httpErrorToHuman } from '@/api/http'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import saveFileContents from '@/api/server/files/saveFileContents'; @@ -15,6 +12,10 @@ import PageContentBlock from '@/components/elements/PageContentBlock'; import ServerError from '@/components/screens/ServerError'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; +import Select from '@/components/elements/Select'; +import modes from '@/modes'; +import useServer from '@/plugins/useServer'; +import useFlash from '@/plugins/useFlash'; const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor')); @@ -24,12 +25,13 @@ export default () => { const [ loading, setLoading ] = useState(action === 'edit'); const [ content, setContent ] = useState(''); const [ modalVisible, setModalVisible ] = useState(false); + const [ mode, setMode ] = useState('plain_text'); const history = useHistory(); const { hash } = useLocation(); - const { id, uuid } = ServerContext.useStoreState(state => state.server.data!); - const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + const { id, uuid } = useServer(); + const { addError, clearFlashes } = useFlash(); let fetchFileContent: null | (() => Promise) = null; @@ -75,10 +77,7 @@ export default () => { if (error) { return ( - history.goBack()} - /> + history.goBack()}/> ); } @@ -109,15 +108,24 @@ export default () => {
{ fetchFileContent = value; }} - onContentSaved={() => save()} + onContentSaved={save} />
+
+ +
{action === 'edit' ? - )} - > -
- - doDownload()}> - - Download + {backup.isSuccessful ? + ( + + )} + > +
+ + doDownload()}> + + Download + + + setVisible(true)}> + + Checksum - - setVisible(true)}> - - Checksum - - - setDeleteVisible(true)}> - - Delete - - -
-
+ + setDeleteVisible(true)}> + + Delete + + +
+ + : + + } ); }; diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx index 2a7b625bc..e6a16a2f1 100644 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { ServerBackup } from '@/api/server/backups/getServerBackups'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; import { format, formatDistanceToNow } from 'date-fns'; @@ -7,10 +6,11 @@ import Spinner from '@/components/elements/Spinner'; import { bytesToHuman } from '@/helpers'; import Can from '@/components/elements/Can'; import useWebsocketEvent from '@/plugins/useWebsocketEvent'; -import { ServerContext } from '@/state/server'; import BackupContextMenu from '@/components/server/backups/BackupContextMenu'; import tw from 'twin.macro'; import GreyRowBox from '@/components/elements/GreyRowBox'; +import getServerBackups from '@/api/swr/getServerBackups'; +import { ServerBackup } from '@/api/server/types'; interface Props { backup: ServerBackup; @@ -18,17 +18,22 @@ interface Props { } export default ({ backup, className }: Props) => { - const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup); + const { mutate } = getServerBackups(); useWebsocketEvent(`backup completed:${backup.uuid}`, data => { try { const parsed = JSON.parse(data); - appendBackup({ - ...backup, - sha256Hash: parsed.sha256_hash || '', - bytes: parsed.file_size || 0, - completedAt: new Date(), - }); + + mutate(data => ({ + ...data, + items: data.items.map(b => b.uuid !== backup.uuid ? b : ({ + ...b, + isSuccessful: parsed.is_successful || true, + sha256Hash: parsed.sha256_hash || '', + bytes: parsed.file_size || 0, + completedAt: new Date(), + })), + }), false); } catch (e) { console.warn(e); } @@ -45,8 +50,13 @@ export default ({ backup, className }: Props) => {

+ {!backup.isSuccessful && + + Failed + + } {backup.name} - {backup.completedAt && + {(backup.completedAt && backup.isSuccessful) && {bytesToHuman(backup.bytes)} }

diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx index 3d7834fa9..3fd53403a 100644 --- a/resources/scripts/components/server/backups/CreateBackupButton.tsx +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -7,12 +7,11 @@ import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; import useFlash from '@/plugins/useFlash'; import useServer from '@/plugins/useServer'; import createServerBackup from '@/api/server/backups/createServerBackup'; -import { httpErrorToHuman } from '@/api/http'; import FlashMessageRender from '@/components/FlashMessageRender'; -import { ServerContext } from '@/state/server'; import Button from '@/components/elements/Button'; import tw from 'twin.macro'; import { Textarea } from '@/components/elements/Input'; +import getServerBackups from '@/api/swr/getServerBackups'; interface Values { name: string; @@ -60,10 +59,9 @@ const ModalContent = ({ ...props }: RequiredModalProps) => { export default () => { const { uuid } = useServer(); - const { addError, clearFlashes } = useFlash(); + const { clearFlashes, clearAndAddHttpError } = useFlash(); const [ visible, setVisible ] = useState(false); - - const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup); + const { mutate } = getServerBackups(); useEffect(() => { clearFlashes('backups:create'); @@ -73,12 +71,11 @@ export default () => { clearFlashes('backups:create'); createServerBackup(uuid, name, ignored) .then(backup => { - appendBackup(backup); + mutate(data => ({ ...data, items: data.items.concat(backup) }), false); setVisible(false); }) .catch(error => { - console.error(error); - addError({ key: 'backups:create', message: httpErrorToHuman(error) }); + clearAndAddHttpError({ key: 'backups:create', error }); setSubmitting(false); }); }; diff --git a/resources/scripts/state/server/backups.ts b/resources/scripts/state/server/backups.ts deleted file mode 100644 index aa24bdf7f..000000000 --- a/resources/scripts/state/server/backups.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ServerBackup } from '@/api/server/backups/getServerBackups'; -import { action, Action } from 'easy-peasy'; - -export interface ServerBackupStore { - data: ServerBackup[]; - setBackups: Action; - appendBackup: Action; - removeBackup: Action; -} - -const backups: ServerBackupStore = { - data: [], - - setBackups: action((state, payload) => { - state.data = payload; - }), - - appendBackup: action((state, payload) => { - if (state.data.find(backup => backup.uuid === payload.uuid)) { - state.data = state.data.map(backup => backup.uuid === payload.uuid ? payload : backup); - } else { - state.data = [ ...state.data, payload ]; - } - }), - - removeBackup: action((state, payload) => { - state.data = [ ...state.data.filter(backup => backup.uuid !== payload) ]; - }), -}; - -export default backups; diff --git a/resources/scripts/state/server/index.ts b/resources/scripts/state/server/index.ts index febf0951a..87023a020 100644 --- a/resources/scripts/state/server/index.ts +++ b/resources/scripts/state/server/index.ts @@ -4,7 +4,6 @@ import socket, { SocketStore } from './socket'; import files, { ServerFileStore } from '@/state/server/files'; import subusers, { ServerSubuserStore } from '@/state/server/subusers'; import { composeWithDevTools } from 'redux-devtools-extension'; -import backups, { ServerBackupStore } from '@/state/server/backups'; import schedules, { ServerScheduleStore } from '@/state/server/schedules'; import databases, { ServerDatabaseStore } from '@/state/server/databases'; @@ -56,7 +55,6 @@ export interface ServerStore { databases: ServerDatabaseStore; files: ServerFileStore; schedules: ServerScheduleStore; - backups: ServerBackupStore; socket: SocketStore; status: ServerStatusStore; clearServerState: Action; @@ -69,7 +67,6 @@ export const ServerContext = createContextStore({ databases, files, subusers, - backups, schedules, clearServerState: action(state => { state.server.data = undefined; @@ -78,7 +75,6 @@ export const ServerContext = createContextStore({ state.subusers.data = []; state.files.directory = '/'; state.files.selectedFiles = []; - state.backups.data = []; state.schedules.data = []; if (state.socket.instance) { From b5713ff7b7383156b94881cc400a775090d8857d Mon Sep 17 00:00:00 2001 From: Jan Date: Fri, 21 Aug 2020 15:10:17 +0200 Subject: [PATCH 53/58] Fix schedules in Dockerfile The wrong directory to run PHP in, so schedules will not run --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0525e5b39..f00d54d5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN cp docker/default.conf /etc/nginx/conf.d/default.conf \ && cat docker/www.conf > /usr/local/etc/php-fpm.d/www.conf \ && rm /usr/local/etc/php-fpm.d/www.conf.default \ && cat docker/supervisord.conf > /etc/supervisord.conf \ - && echo "* * * * * /usr/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \ + && echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \ && sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \ && mkdir -p /var/run/php /var/run/nginx @@ -33,4 +33,4 @@ EXPOSE 80 443 ENTRYPOINT ["/bin/ash", "docker/entrypoint.sh"] -CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ] \ No newline at end of file +CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ] From 3a2c60ce316e109501890e5428b158bddc202de0 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 22 Aug 2020 13:26:03 -0700 Subject: [PATCH 54/58] Store bytes as unsigned bigint; closes #2245 --- ...132500_update_bytes_to_unsigned_bigint.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 database/migrations/2020_08_22_132500_update_bytes_to_unsigned_bigint.php diff --git a/database/migrations/2020_08_22_132500_update_bytes_to_unsigned_bigint.php b/database/migrations/2020_08_22_132500_update_bytes_to_unsigned_bigint.php new file mode 100644 index 000000000..802994ebe --- /dev/null +++ b/database/migrations/2020_08_22_132500_update_bytes_to_unsigned_bigint.php @@ -0,0 +1,32 @@ +unsignedBigInteger('bytes')->default(0)->change(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('backups', function (Blueprint $table) { + $table->integer('bytes')->default(0)->change(); + }); + } +} From cae604e79dc580e63fced328e59e88253551007e Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 22 Aug 2020 15:43:28 -0700 Subject: [PATCH 55/58] Include egg variables in the output from the API --- app/Models/EggVariable.php | 43 ++++++++++-- app/Models/Server.php | 6 +- .../Eloquent/ServerRepository.php | 4 ++ .../Servers/StartupCommandService.php | 27 ++++++++ .../Servers/StartupCommandViewService.php | 56 ---------------- .../Api/Client/EggVariableTransformer.php | 33 ++++++++++ .../Api/Client/ServerTransformer.php | 24 ++++++- resources/scripts/api/server/getServer.ts | 2 + .../components/elements/PageContentBlock.tsx | 65 ++++++++++++------- .../server/startup/StartupContainer.tsx | 23 +++++++ resources/scripts/routers/ServerRouter.tsx | 5 ++ .../Servers/StartupCommandViewServiceTest.php | 8 +-- 12 files changed, 204 insertions(+), 92 deletions(-) create mode 100644 app/Services/Servers/StartupCommandService.php delete mode 100644 app/Services/Servers/StartupCommandViewService.php create mode 100644 app/Transformers/Api/Client/EggVariableTransformer.php create mode 100644 resources/scripts/components/server/startup/StartupContainer.tsx diff --git a/app/Models/EggVariable.php b/app/Models/EggVariable.php index 2db891dc9..c6cc45b56 100644 --- a/app/Models/EggVariable.php +++ b/app/Models/EggVariable.php @@ -2,6 +2,27 @@ namespace Pterodactyl\Models; +/** + * @property int $id + * @property int $egg_id + * @property string $name + * @property string $description + * @property string $env_variable + * @property string $default_value + * @property bool $user_viewable + * @property bool $user_editable + * @property string $rules + * @property \Carbon\CarbonImmutable $created_at + * @property \Carbon\CarbonImmutable $updated_at + * + * @property bool $required + * @property \Pterodactyl\Models\Egg $egg + * @property \Pterodactyl\Models\ServerVariable $serverVariable + * + * The "server_value" variable is only present on the object if you've loaded this model + * using the server relationship. + * @property string|null $server_value + */ class EggVariable extends Model { /** @@ -17,6 +38,11 @@ class EggVariable extends Model */ const RESERVED_ENV_NAMES = 'SERVER_MEMORY,SERVER_IP,SERVER_PORT,ENV,HOME,USER,STARTUP,SERVER_UUID,UUID'; + /** + * @var bool + */ + protected $immutableDates = true; + /** * The table associated with the model. * @@ -38,8 +64,8 @@ class EggVariable extends Model */ protected $casts = [ 'egg_id' => 'integer', - 'user_viewable' => 'integer', - 'user_editable' => 'integer', + 'user_viewable' => 'bool', + 'user_editable' => 'bool', ]; /** @@ -65,12 +91,19 @@ class EggVariable extends Model ]; /** - * @param $value * @return bool */ - public function getRequiredAttribute($value) + public function getRequiredAttribute() { - return $this->rules === 'required' || str_contains($this->rules, ['required|', '|required']); + return in_array('required', explode('|', $this->rules)); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasOne + */ + public function egg() + { + return $this->hasOne(Egg::class); } /** diff --git a/app/Models/Server.php b/app/Models/Server.php index 8894a4d60..e6e9bca72 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -45,7 +45,7 @@ use Znck\Eloquent\Traits\BelongsToThrough; * @property \Pterodactyl\Models\Node $node * @property \Pterodactyl\Models\Nest $nest * @property \Pterodactyl\Models\Egg $egg - * @property \Pterodactyl\Models\ServerVariable[]|\Illuminate\Database\Eloquent\Collection $variables + * @property \Pterodactyl\Models\EggVariable[]|\Illuminate\Database\Eloquent\Collection $variables * @property \Pterodactyl\Models\Schedule[]|\Illuminate\Database\Eloquent\Collection $schedule * @property \Pterodactyl\Models\Database[]|\Illuminate\Database\Eloquent\Collection $databases * @property \Pterodactyl\Models\Location $location @@ -270,7 +270,9 @@ class Server extends Model */ public function variables() { - return $this->hasMany(ServerVariable::class); + return $this->hasMany(EggVariable::class, 'egg_id', 'egg_id') + ->select(['egg_variables.*', 'server_variables.variable_value as server_value']) + ->leftJoin('server_variables', 'server_variables.variable_id', '=', 'egg_variables.id'); } /** diff --git a/app/Repositories/Eloquent/ServerRepository.php b/app/Repositories/Eloquent/ServerRepository.php index a64f68db9..0f7919305 100644 --- a/app/Repositories/Eloquent/ServerRepository.php +++ b/app/Repositories/Eloquent/ServerRepository.php @@ -143,6 +143,10 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt */ public function getVariablesWithValues(int $id, bool $returnAsObject = false) { + $this->getBuilder() + ->with('variables', 'egg.variables') + ->findOrFail($id); + try { $instance = $this->getBuilder()->with('variables', 'egg.variables')->find($id, $this->getColumns()); } catch (ModelNotFoundException $exception) { diff --git a/app/Services/Servers/StartupCommandService.php b/app/Services/Servers/StartupCommandService.php new file mode 100644 index 000000000..5ee170aa0 --- /dev/null +++ b/app/Services/Servers/StartupCommandService.php @@ -0,0 +1,27 @@ +memory, $server->allocation->ip, $server->allocation->port]; + + foreach ($server->variables as $variable) { + $find[] = '{{' . $variable->env_variable . '}}'; + $replace[] = $variable->user_viewable ? ($variable->server_value ?? $variable->default_value) : '[hidden]'; + } + + return str_replace($find, $replace, $server->startup); + } +} diff --git a/app/Services/Servers/StartupCommandViewService.php b/app/Services/Servers/StartupCommandViewService.php deleted file mode 100644 index d3cda3143..000000000 --- a/app/Services/Servers/StartupCommandViewService.php +++ /dev/null @@ -1,56 +0,0 @@ -repository = $repository; - } - - /** - * Generate a startup command for a server and return all of the user-viewable variables - * as well as their assigned values. - * - * @param int $server - * @return \Illuminate\Support\Collection - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function handle(int $server): Collection - { - $response = $this->repository->getVariablesWithValues($server, true); - $server = $this->repository->getPrimaryAllocation($response->server); - - $find = ['{{SERVER_MEMORY}}', '{{SERVER_IP}}', '{{SERVER_PORT}}']; - $replace = [$server->memory, $server->getRelation('allocation')->ip, $server->getRelation('allocation')->port]; - - $variables = $server->getRelation('egg')->getRelation('variables') - ->each(function ($variable) use (&$find, &$replace, $response) { - $find[] = '{{' . $variable->env_variable . '}}'; - $replace[] = $variable->user_viewable ? $response->data[$variable->env_variable] : '[hidden]'; - })->filter(function ($variable) { - return $variable->user_viewable === 1; - }); - - return collect([ - 'startup' => str_replace($find, $replace, $server->startup), - 'variables' => $variables, - 'server_values' => $response->data, - ]); - } -} diff --git a/app/Transformers/Api/Client/EggVariableTransformer.php b/app/Transformers/Api/Client/EggVariableTransformer.php new file mode 100644 index 000000000..62be843f2 --- /dev/null +++ b/app/Transformers/Api/Client/EggVariableTransformer.php @@ -0,0 +1,33 @@ + $variable->name, + 'description' => $variable->description, + 'env_variable' => $variable->env_variable, + 'default_value' => $variable->default_value, + 'server_value' => $variable->server_value, + 'is_editable' => $variable->user_editable, + 'rules' => $variable->rules, + ]; + } +} diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 148fd8990..e1e7f529e 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -6,13 +6,17 @@ use Pterodactyl\Models\Egg; use Pterodactyl\Models\Server; use Pterodactyl\Models\Subuser; use Pterodactyl\Models\Allocation; +use Illuminate\Container\Container; +use Pterodactyl\Models\EggVariable; +use Pterodactyl\Services\Servers\StartupCommandService; +use Pterodactyl\Transformers\Api\Client\EggVariableTransformer; class ServerTransformer extends BaseClientTransformer { /** * @var string[] */ - protected $defaultIncludes = ['allocations']; + protected $defaultIncludes = ['allocations', 'variables']; /** * @var array @@ -36,6 +40,9 @@ class ServerTransformer extends BaseClientTransformer */ public function transform(Server $server): array { + /** @var \Pterodactyl\Services\Servers\StartupCommandService $service */ + $service = Container::getInstance()->make(StartupCommandService::class); + return [ 'server_owner' => $this->getKey()->user_id === $server->owner_id, 'identifier' => $server->uuidShort, @@ -54,6 +61,7 @@ class ServerTransformer extends BaseClientTransformer 'io' => $server->io, 'cpu' => $server->cpu, ], + 'invocation' => $service->handle($server), 'feature_limits' => [ 'databases' => $server->database_limit, 'allocations' => $server->allocation_limit, @@ -80,6 +88,20 @@ class ServerTransformer extends BaseClientTransformer ); } + /** + * @param \Pterodactyl\Models\Server $server + * @return \League\Fractal\Resource\Collection + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includeVariables(Server $server) + { + return $this->collection( + $server->variables->where('user_viewable', true), + $this->makeTransformer(EggVariableTransformer::class), + EggVariable::RESOURCE_NAME + ); + } + /** * Returns the egg associated with this server. * diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index 7072033f1..36dcffda9 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -19,6 +19,7 @@ export interface Server { ip: string; port: number; }; + invocation: string; description: string; allocations: Allocation[]; limits: { @@ -43,6 +44,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) uuid: data.uuid, name: data.name, node: data.node, + invocation: data.invocation, sftpDetails: { ip: data.sftp_details.ip, port: data.sftp_details.port, diff --git a/resources/scripts/components/elements/PageContentBlock.tsx b/resources/scripts/components/elements/PageContentBlock.tsx index f32c42ce2..392cffb8d 100644 --- a/resources/scripts/components/elements/PageContentBlock.tsx +++ b/resources/scripts/components/elements/PageContentBlock.tsx @@ -3,31 +3,48 @@ import ContentContainer from '@/components/elements/ContentContainer'; import { CSSTransition } from 'react-transition-group'; import tw from 'twin.macro'; import FlashMessageRender from '@/components/FlashMessageRender'; +import { Helmet } from 'react-helmet'; +import useServer from '@/plugins/useServer'; -const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => ( - - <> - - {showFlashKey && - +interface Props { + title?: string; + className?: string; + showFlashKey?: string; +} + +const PageContentBlock: React.FC = ({ title, showFlashKey, className, children }) => { + const { name } = useServer(); + + return ( + + <> + {!!title && + + {name} | {title} + } - {children} - - -

- © 2015 - 2020  - - Pterodactyl Software - -

-
- -
-); + + {showFlashKey && + + } + {children} + + +

+ © 2015 - 2020  + + Pterodactyl Software + +

+
+ + + ); +}; export default PageContentBlock; diff --git a/resources/scripts/components/server/startup/StartupContainer.tsx b/resources/scripts/components/server/startup/StartupContainer.tsx new file mode 100644 index 000000000..e689b4982 --- /dev/null +++ b/resources/scripts/components/server/startup/StartupContainer.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import TitledGreyBox from '@/components/elements/TitledGreyBox'; +import useServer from '@/plugins/useServer'; +import tw from 'twin.macro'; + +const StartupContainer = () => { + const { invocation } = useServer(); + + return ( + + +
+

+ {invocation} +

+
+
+
+ ); +}; + +export default StartupContainer; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 3fa5a9ff4..22e701fa9 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -27,6 +27,7 @@ import ScreenBlock from '@/components/screens/ScreenBlock'; import SubNavigation from '@/components/elements/SubNavigation'; import NetworkContainer from '@/components/server/network/NetworkContainer'; import InstallListener from '@/components/server/InstallListener'; +import StartupContainer from '@/components/server/startup/StartupContainer'; const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => { const { rootAdmin } = useStoreState(state => state.user.data!); @@ -98,6 +99,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) Network + + Startup + Settings @@ -137,6 +141,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) + diff --git a/tests/Unit/Services/Servers/StartupCommandViewServiceTest.php b/tests/Unit/Services/Servers/StartupCommandViewServiceTest.php index 5bb436122..a16eb3865 100644 --- a/tests/Unit/Services/Servers/StartupCommandViewServiceTest.php +++ b/tests/Unit/Services/Servers/StartupCommandViewServiceTest.php @@ -9,7 +9,7 @@ use Pterodactyl\Models\Server; use Illuminate\Support\Collection; use Pterodactyl\Models\Allocation; use Pterodactyl\Models\EggVariable; -use Pterodactyl\Services\Servers\StartupCommandViewService; +use Pterodactyl\Services\Servers\StartupCommandService; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; class StartupCommandViewServiceTest extends TestCase @@ -76,10 +76,10 @@ class StartupCommandViewServiceTest extends TestCase /** * Return an instance of the service with mocked dependencies. * - * @return \Pterodactyl\Services\Servers\StartupCommandViewService + * @return \Pterodactyl\Services\Servers\StartupCommandService */ - private function getService(): StartupCommandViewService + private function getService(): StartupCommandService { - return new StartupCommandViewService($this->repository); + return new StartupCommandService($this->repository); } } From 9b16f5883c755af5584266390e8d5a52110c5221 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 22 Aug 2020 15:46:13 -0700 Subject: [PATCH 56/58] Refactor to a single transformer file --- .../api/server/backups/createServerBackup.ts | 2 +- resources/scripts/api/server/transformers.ts | 13 ------------- resources/scripts/api/swr/getServerBackups.ts | 2 +- resources/scripts/api/transformers.ts | 12 ++++++++++++ 4 files changed, 14 insertions(+), 15 deletions(-) delete mode 100644 resources/scripts/api/server/transformers.ts diff --git a/resources/scripts/api/server/backups/createServerBackup.ts b/resources/scripts/api/server/backups/createServerBackup.ts index f86088994..a27d5d146 100644 --- a/resources/scripts/api/server/backups/createServerBackup.ts +++ b/resources/scripts/api/server/backups/createServerBackup.ts @@ -1,6 +1,6 @@ import http from '@/api/http'; import { ServerBackup } from '@/api/server/types'; -import { rawDataToServerBackup } from '@/api/server/transformers'; +import { rawDataToServerBackup } from '@/api/transformers'; export default (uuid: string, name?: string, ignored?: string): Promise => { return new Promise((resolve, reject) => { diff --git a/resources/scripts/api/server/transformers.ts b/resources/scripts/api/server/transformers.ts deleted file mode 100644 index f6f98e054..000000000 --- a/resources/scripts/api/server/transformers.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FractalResponseData } from '@/api/http'; -import { ServerBackup } from '@/api/server/types'; - -export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({ - uuid: attributes.uuid, - isSuccessful: attributes.is_successful, - name: attributes.name, - ignoredFiles: attributes.ignored_files, - sha256Hash: attributes.sha256_hash, - bytes: attributes.bytes, - createdAt: new Date(attributes.created_at), - completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null, -}); diff --git a/resources/scripts/api/swr/getServerBackups.ts b/resources/scripts/api/swr/getServerBackups.ts index b07a5bea3..d7487fde3 100644 --- a/resources/scripts/api/swr/getServerBackups.ts +++ b/resources/scripts/api/swr/getServerBackups.ts @@ -1,7 +1,7 @@ import useSWR from 'swr'; import http, { getPaginationSet, PaginatedResult } from '@/api/http'; import { ServerBackup } from '@/api/server/types'; -import { rawDataToServerBackup } from '@/api/server/transformers'; +import { rawDataToServerBackup } from '@/api/transformers'; import useServer from '@/plugins/useServer'; export default (page?: number | string) => { diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index 6ac0ba1dd..5f9d337ae 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -1,6 +1,7 @@ import { Allocation } from '@/api/server/getServer'; import { FractalResponseData } from '@/api/http'; import { FileObject } from '@/api/server/files/loadDirectory'; +import { ServerBackup } from '@/api/server/types'; export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({ id: data.attributes.id, @@ -39,3 +40,14 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({ ].indexOf(this.mimetype) >= 0; }, }); + +export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({ + uuid: attributes.uuid, + isSuccessful: attributes.is_successful, + name: attributes.name, + ignoredFiles: attributes.ignored_files, + sha256Hash: attributes.sha256_hash, + bytes: attributes.bytes, + createdAt: new Date(attributes.created_at), + completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null, +}); From 1b69d82daac34d4c58ff9a26cefd8f292e119e10 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 22 Aug 2020 16:54:12 -0700 Subject: [PATCH 57/58] Don't return things a user shouldn't be able to see via the API includes --- .../Api/Client/DatabaseTransformer.php | 13 +++++++---- .../Api/Client/ServerTransformer.php | 23 +++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/app/Transformers/Api/Client/DatabaseTransformer.php b/app/Transformers/Api/Client/DatabaseTransformer.php index 8d420ea83..ddf02af10 100644 --- a/app/Transformers/Api/Client/DatabaseTransformer.php +++ b/app/Transformers/Api/Client/DatabaseTransformer.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Transformers\Api\Client; use Pterodactyl\Models\Database; use League\Fractal\Resource\Item; +use Pterodactyl\Models\Permission; use Illuminate\Contracts\Encryption\Encrypter; use Pterodactyl\Contracts\Extensions\HashidsInterface; @@ -65,12 +66,16 @@ class DatabaseTransformer extends BaseClientTransformer /** * Include the database password in the request. * - * @param \Pterodactyl\Models\Database $model - * @return \League\Fractal\Resource\Item + * @param \Pterodactyl\Models\Database $database + * @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource */ - public function includePassword(Database $model): Item + public function includePassword(Database $database): Item { - return $this->item($model, function (Database $model) { + if (!$this->getUser()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $database->server)) { + return $this->null(); + } + + return $this->item($database, function (Database $model) { return [ 'password' => $this->encrypter->decrypt($model->password), ]; diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index e1e7f529e..6d5b86ac5 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -6,10 +6,10 @@ use Pterodactyl\Models\Egg; use Pterodactyl\Models\Server; use Pterodactyl\Models\Subuser; use Pterodactyl\Models\Allocation; +use Pterodactyl\Models\Permission; use Illuminate\Container\Container; use Pterodactyl\Models\EggVariable; use Pterodactyl\Services\Servers\StartupCommandService; -use Pterodactyl\Transformers\Api\Client\EggVariableTransformer; class ServerTransformer extends BaseClientTransformer { @@ -76,11 +76,16 @@ class ServerTransformer extends BaseClientTransformer * Returns the allocations associated with this server. * * @param \Pterodactyl\Models\Server $server - * @return \League\Fractal\Resource\Collection + * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException */ public function includeAllocations(Server $server) { + if (! $this->getUser()->can(Permission::ACTION_ALLOCATION_READ, $server)) { + return $this->null(); + } + return $this->collection( $server->allocations, $this->makeTransformer(AllocationTransformer::class), @@ -90,11 +95,16 @@ class ServerTransformer extends BaseClientTransformer /** * @param \Pterodactyl\Models\Server $server - * @return \League\Fractal\Resource\Collection + * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException */ public function includeVariables(Server $server) { + if (! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)) { + return $this->null(); + } + return $this->collection( $server->variables->where('user_viewable', true), $this->makeTransformer(EggVariableTransformer::class), @@ -118,11 +128,16 @@ class ServerTransformer extends BaseClientTransformer * Returns the subusers associated with this server. * * @param \Pterodactyl\Models\Server $server - * @return \League\Fractal\Resource\Collection + * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException */ public function includeSubusers(Server $server) { + if (! $this->getUser()->can(Permission::ACTION_USER_READ, $server)) { + return $this->null(); + } + return $this->collection($server->subusers, $this->makeTransformer(SubuserTransformer::class), Subuser::RESOURCE_NAME); } } From 91cdbd6c2e807b15a4d4748d464311a416b5c132 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 22 Aug 2020 18:13:59 -0700 Subject: [PATCH 58/58] Support modifying startup variables for servers --- .../Api/Client/Servers/StartupController.php | 81 +++++++++++++++++++ .../Startup/UpdateStartupVariableRequest.php | 30 +++++++ app/Models/Permission.php | 7 +- resources/scripts/.eslintrc.yml | 2 + resources/scripts/api/server/getServer.ts | 7 +- resources/scripts/api/server/types.d.ts | 10 +++ .../api/server/updateStartupVariable.ts | 9 +++ resources/scripts/api/transformers.ts | 12 ++- .../server/startup/StartupContainer.tsx | 6 +- .../components/server/startup/VariableBox.tsx | 64 +++++++++++++++ routes/api-client.php | 4 + 11 files changed, 226 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Api/Client/Servers/StartupController.php create mode 100644 app/Http/Requests/Api/Client/Servers/Startup/UpdateStartupVariableRequest.php create mode 100644 resources/scripts/api/server/updateStartupVariable.ts create mode 100644 resources/scripts/components/server/startup/VariableBox.tsx diff --git a/app/Http/Controllers/Api/Client/Servers/StartupController.php b/app/Http/Controllers/Api/Client/Servers/StartupController.php new file mode 100644 index 000000000..6eb1df0ad --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/StartupController.php @@ -0,0 +1,81 @@ +service = $service; + $this->repository = $repository; + } + + /** + * Updates a single variable for a server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Startup\UpdateStartupVariableRequest $request + * @param \Pterodactyl\Models\Server $server + * @return array + * + * @throws \Illuminate\Validation\ValidationException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function update(UpdateStartupVariableRequest $request, Server $server) + { + /** @var \Pterodactyl\Models\EggVariable $variable */ + $variable = $server->variables()->where('env_variable', $request->input('key'))->first(); + + if (is_null($variable) || !$variable->user_viewable || !$variable->user_editable) { + throw new BadRequestHttpException( + "The environment variable you are trying to edit [\"{$request->input('key')}\"] does not exist." + ); + } + + // Revalidate the variable value using the egg variable specific validation rules for it. + $this->validate($request, ['value' => $variable->rules]); + + $this->repository->updateOrCreate([ + 'server_id' => $server->id, + 'variable_id' => $variable->id, + ], [ + 'variable_value' => $request->input('value'), + ]); + + $variable = $variable->refresh(); + $variable->server_value = $request->input('value'); + + return $this->fractal->item($variable) + ->transformWith($this->getTransformer(EggVariableTransformer::class)) + ->toArray(); + } +} diff --git a/app/Http/Requests/Api/Client/Servers/Startup/UpdateStartupVariableRequest.php b/app/Http/Requests/Api/Client/Servers/Startup/UpdateStartupVariableRequest.php new file mode 100644 index 000000000..63005c78b --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Startup/UpdateStartupVariableRequest.php @@ -0,0 +1,30 @@ + 'required|string', + 'value' => 'present|string', + ]; + } +} diff --git a/app/Models/Permission.php b/app/Models/Permission.php index af3dc5cf9..a7eb2709b 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -55,6 +55,9 @@ class Permission extends Model const ACTION_FILE_ARCHIVE = 'file.archive'; const ACTION_FILE_SFTP = 'file.sftp'; + const ACTION_STARTUP_READ = 'startup.read'; + const ACTION_STARTUP_UPDATE = 'startup.update'; + const ACTION_SETTINGS_RENAME = 'settings.rename'; const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; @@ -169,8 +172,8 @@ class Permission extends Model 'startup' => [ 'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.', 'keys' => [ - 'read' => '', - 'update' => '', + 'read' => 'Allows a user to view the startup variables for a server.', + 'update' => 'Allows a user to modify the startup variables for the server.', ], ], diff --git a/resources/scripts/.eslintrc.yml b/resources/scripts/.eslintrc.yml index 0e22c8f66..b18f90af9 100644 --- a/resources/scripts/.eslintrc.yml +++ b/resources/scripts/.eslintrc.yml @@ -39,6 +39,8 @@ rules: comma-dangle: - warn - always-multiline + spaced-comment: + - warn array-bracket-spacing: - warn - always diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index 36dcffda9..278b21e17 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -1,5 +1,6 @@ import http, { FractalResponseData, FractalResponseList } from '@/api/http'; -import { rawDataToServerAllocation } from '@/api/transformers'; +import { rawDataToServerAllocation, rawDataToServerEggVariable } from '@/api/transformers'; +import { ServerEggVariable } from '@/api/server/types'; export interface Allocation { id: number; @@ -21,7 +22,6 @@ export interface Server { }; invocation: string; description: string; - allocations: Allocation[]; limits: { memory: number; swap: number; @@ -37,6 +37,8 @@ export interface Server { }; isSuspended: boolean; isInstalling: boolean; + variables: ServerEggVariable[]; + allocations: Allocation[]; } export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({ @@ -54,6 +56,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) featureLimits: { ...data.feature_limits }, isSuspended: data.is_suspended, isInstalling: data.is_installing, + variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable), allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation), }); diff --git a/resources/scripts/api/server/types.d.ts b/resources/scripts/api/server/types.d.ts index bcdd7416d..e11a39c45 100644 --- a/resources/scripts/api/server/types.d.ts +++ b/resources/scripts/api/server/types.d.ts @@ -8,3 +8,13 @@ export interface ServerBackup { createdAt: Date; completedAt: Date | null; } + +export interface ServerEggVariable { + name: string; + description: string; + envVariable: string; + defaultValue: string; + serverValue: string; + isEditable: boolean; + rules: string[]; +} diff --git a/resources/scripts/api/server/updateStartupVariable.ts b/resources/scripts/api/server/updateStartupVariable.ts new file mode 100644 index 000000000..88231eccc --- /dev/null +++ b/resources/scripts/api/server/updateStartupVariable.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; +import { ServerEggVariable } from '@/api/server/types'; +import { rawDataToServerEggVariable } from '@/api/transformers'; + +export default async (uuid: string, key: string, value: string): Promise => { + const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value }); + + return rawDataToServerEggVariable(data); +}; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index 5f9d337ae..53ee514ed 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -1,7 +1,7 @@ import { Allocation } from '@/api/server/getServer'; import { FractalResponseData } from '@/api/http'; import { FileObject } from '@/api/server/files/loadDirectory'; -import { ServerBackup } from '@/api/server/types'; +import { ServerBackup, ServerEggVariable } from '@/api/server/types'; export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({ id: data.attributes.id, @@ -51,3 +51,13 @@ export const rawDataToServerBackup = ({ attributes }: FractalResponseData): Serv createdAt: new Date(attributes.created_at), completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null, }); + +export const rawDataToServerEggVariable = ({ attributes }: FractalResponseData): ServerEggVariable => ({ + name: attributes.name, + description: attributes.description, + envVariable: attributes.env_variable, + defaultValue: attributes.default_value, + serverValue: attributes.server_value, + isEditable: attributes.is_editable, + rules: attributes.rules.split('|'), +}); diff --git a/resources/scripts/components/server/startup/StartupContainer.tsx b/resources/scripts/components/server/startup/StartupContainer.tsx index e689b4982..481293145 100644 --- a/resources/scripts/components/server/startup/StartupContainer.tsx +++ b/resources/scripts/components/server/startup/StartupContainer.tsx @@ -3,9 +3,10 @@ import PageContentBlock from '@/components/elements/PageContentBlock'; import TitledGreyBox from '@/components/elements/TitledGreyBox'; import useServer from '@/plugins/useServer'; import tw from 'twin.macro'; +import VariableBox from '@/components/server/startup/VariableBox'; const StartupContainer = () => { - const { invocation } = useServer(); + const { invocation, variables } = useServer(); return ( @@ -16,6 +17,9 @@ const StartupContainer = () => {

+
+ {variables.map(variable => )} +
); }; diff --git a/resources/scripts/components/server/startup/VariableBox.tsx b/resources/scripts/components/server/startup/VariableBox.tsx new file mode 100644 index 000000000..e9e7b58f0 --- /dev/null +++ b/resources/scripts/components/server/startup/VariableBox.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import { ServerEggVariable } from '@/api/server/types'; +import TitledGreyBox from '@/components/elements/TitledGreyBox'; +import { usePermissions } from '@/plugins/usePermissions'; +import InputSpinner from '@/components/elements/InputSpinner'; +import Input from '@/components/elements/Input'; +import tw from 'twin.macro'; +import { debounce } from 'debounce'; +import updateStartupVariable from '@/api/server/updateStartupVariable'; +import useServer from '@/plugins/useServer'; +import { ServerContext } from '@/state/server'; +import useFlash from '@/plugins/useFlash'; +import FlashMessageRender from '@/components/FlashMessageRender'; + +interface Props { + variable: ServerEggVariable; +} + +const VariableBox = ({ variable }: Props) => { + const FLASH_KEY = `server:startup:${variable.envVariable}`; + + const server = useServer(); + const [ loading, setLoading ] = useState(false); + const [ canEdit ] = usePermissions([ 'startup.update' ]); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const setServer = ServerContext.useStoreActions(actions => actions.server.setServer); + + const setVariableValue = debounce((value: string) => { + setLoading(true); + clearFlashes(FLASH_KEY); + + updateStartupVariable(server.uuid, variable.envVariable, value) + .then(response => setServer({ + ...server, + variables: server.variables.map(v => v.envVariable === response.envVariable ? response : v), + })) + .catch(error => { + console.error(error); + clearAndAddHttpError({ error, key: FLASH_KEY }); + }) + .then(() => setLoading(false)); + }, 500); + + return ( + + + + setVariableValue(e.currentTarget.value)} + readOnly={!canEdit} + name={variable.envVariable} + defaultValue={variable.serverValue} + placeholder={variable.defaultValue} + /> + +

+ {variable.description} +

+
+ ); +}; + +export default VariableBox; diff --git a/routes/api-client.php b/routes/api-client.php index c9ec16097..c3dfefd83 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -101,6 +101,10 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::delete('/{backup}', 'Servers\BackupController@delete'); }); + Route::group(['prefix' => '/startup'], function () { + Route::put('/variable', 'Servers\StartupController@update'); + }); + Route::group(['prefix' => '/settings'], function () { Route::post('/rename', 'Servers\SettingsController@rename'); Route::post('/reinstall', 'Servers\SettingsController@reinstall');