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 4dd56ba1..53c62f2b 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 61df3a53..06582c0e 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 39d2278f..ee756e50 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 8dd8ed22..492898f7 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 692ac2ed..60c18cb1 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 39d2278f..d68744c4 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 1461b801..75eff1bf 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 00000000..1ca625fd --- /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 d4551aa2..b8390673 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 60fc8877..c4e4a035 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 82cf8b33..12e2d75b 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 644bb024..38f2fb47 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 7a0934b1..3a605ea9 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 d7ef42c4..ccbf169f 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 c4e4a035..77f672f4 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 00000000..f8493ec4 --- /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 00000000..d674eadb --- /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 85f29068..7899d221 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 bd59a7e8..c8676bda 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 e75f8e16..19fbe522 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 d1f23c02..9adbf57d 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 9b57cf0c..f92ba6ed 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 3a605ea9..177f22af 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 c8676bda..4548c4b1 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 934fd632..9baf5b53 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 f2f8ba13..2eb7e93c 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 77f672f4..1f18259a 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 b41c7548..abb5dae4 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 52036510..def0944c 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 9baf5b53..a78a83cc 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 50b69478..16721e72 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 785de6c7..985087d1 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 5cc44cea..a38dff74 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 669f04e8..feb4e5f2 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 300770aa..5eec40b5 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 c28f0a94..9b4601f2 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 baff65b6..39954fbf 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 492898f7..63329bfa 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 60c18cb1..a2047260 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 82aeb564..b894b14b 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 3d7cd090..de3dacc8 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 0b02561d..777761b6 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 7e8f82db..6da825ad 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 8a1d4db2..abd88c04 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 99bcf0d3..3a81f98f 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 dac7fd10..350387fa 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 a7c687ee..57d1422c 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 79ebbe4a..7a895a7e 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 9df270ea..2e9ee9ed 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 20dbbdc6..3eb782d9 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 489646dc..5ccec0df 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 62c1da6f..f20fef04 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 99bcf0d3..52d866a0 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 e90c8603..74ba4d75 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 669f04e8..d75fb2a7 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 48607259..462d90fb 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 def0944c..9a1b6891 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 1723f935..4470f681 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 0e4ff6bd..2ee4038c 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 e060fedf..edaa3503 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 55f60b44..0925e87d 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 62c1da6f..253b6cd8 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 ec23c6f1..514d50ac 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 0fc1563a..97989cc3 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 00000000..8bc85778 --- /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 40fd93da..8014ced5 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 9df270ea..3fe87cca 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 7899d221..77e44bce 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 4548c4b1..6ac0ba1d 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 19fbe522..e64dd3d8 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 def0944c..a6d6b89a 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 a78a83cc..0a14aca8 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 9adbf57d..27cfb15f 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 155b45e9..8ecbc9d9 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 00000000..4a3f008f --- /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 7a04f104..3d7834fa 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 3829d724..b8b102ba 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 b8b102ba..00457a4e 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 8ecbc9d9..fb3c0620 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 4a3f008f..00000000 --- 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 f3ceb66f..c80a51a2 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 e98ddd4a..d495400b 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 a2047260..1e1e702c 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 c8abddca..bcead7ab 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 462d90fb..922f0a36 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 380a0951..d25ef3c1 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 4470f681..a330685b 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 2ee4038c..77e31b59 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 0925e87d..a58d9e90 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 3a81f98f..0ca8c2e9 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 d7013989..2168160c 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 dbd4ed46..82bd5e5f 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 8e4fb258..fb89a0a8 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 a6038447..4bdb7220 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 f20fef04..73e239ae 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 b69ecba1..401bcc7d 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 a717a0a9..0bba5d32 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 165d0f9c..346b083e 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 eef96c16..1b7b44de 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 3d98cc33..2659259b 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 2659259b..9d30f8a8 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 9d30f8a8..6f4eae68 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 177f22af..553e39d2 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 70014bc0..b37790cb 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 0554c7fd..4204f088 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 27cfb15f..0a6a2b07 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 53c62f2b..02899d44 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 02899d44..d788fb7c 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 b9daed85..fc849065 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 00000000..4f4c35bd --- /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 0aa13769..0f8150dc 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 2eb7e93c..e6765b8a 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 d788fb7c..eae7dfc6 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 eae7dfc6..84d428ee 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 00197388..a82db1ec 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 4e8fae1d..3e52e68a 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 c80a51a2..304fe563 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 00000000..db511edb --- /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 3e52e68a..9022ae6c 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 62850283..2b9c3efa 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 f242fbac..de0be05e 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 54a45b9d..d9c74150 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 6df43ecc..40067d03 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 26d86652..19806038 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 fb1136f7..b14a24ea 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 1b7b44de..0c1ce8ec 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 976c6417..f6481dfc 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 2311b144..7db437c1 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 f6481dfc..a7fb4ce6 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 50ac1a96..d278ce0b 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 da6fee42..d8bdcc40 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 00000000..894d6b00 --- /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 0bd40eee..77879c97 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 e43b7178..98d0d964 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 8f15bfcf..8894a4d6 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 e00d825e..c0fb930a 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 98dcf6c3..e0ea2037 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 f92ba6ed..c9ec1609 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 894d6b00..a80f6eef 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 54d3bd0c..c6b2468a 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 6fafce2f..d76fed49 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 00000000..e1421b52 --- /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 0b4ebca9..fbea88b8 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 fdde9bb1..4106ad53 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 2a7b625b..e6a16a2f 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 3d7834fa..3fd53403 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 aa24bdf7..00000000 --- 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 febf0951..87023a02 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 0525e5b3..f00d54d5 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 00000000..802994eb --- /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 2db891dc..c6cc45b5 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 8894a4d6..e6e9bca7 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 a64f68db..0f791930 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 00000000..5ee170aa --- /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 d3cda314..00000000 --- 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 00000000..62be843f --- /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 148fd899..e1e7f529 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 7072033f..36dcffda 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 f32c42ce..392cffb8 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 00000000..e689b498 --- /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 3fa5a9ff..22e701fa 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 5bb43612..a16eb386 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 f8608899..a27d5d14 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 f6f98e05..00000000 --- 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 b07a5bea..d7487fde 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 6ac0ba1d..5f9d337a 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 8d420ea8..ddf02af1 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 e1e7f529..6d5b86ac 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 00000000..6eb1df0a --- /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 00000000..63005c78 --- /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 af3dc5cf..a7eb2709 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 0e22c8f6..b18f90af 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 36dcffda..278b21e1 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 bcdd7416..e11a39c4 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 00000000..88231ecc --- /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 5f9d337a..53ee514e 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 e689b498..48129314 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 00000000..e9e7b58f --- /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 c9ec1609..c3dfefd8 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');