diff --git a/app/Http/Controllers/Api/Client/ClientApiController.php b/app/Http/Controllers/Api/Client/ClientApiController.php index e2d4b3f8..7e304617 100644 --- a/app/Http/Controllers/Api/Client/ClientApiController.php +++ b/app/Http/Controllers/Api/Client/ClientApiController.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Http\Controllers\Api\Client; use Webmozart\Assert\Assert; use Illuminate\Container\Container; +use Pterodactyl\Transformers\Daemon\BaseDaemonTransformer; use Pterodactyl\Transformers\Api\Client\BaseClientTransformer; use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController; @@ -19,10 +20,15 @@ abstract class ClientApiController extends ApplicationApiController { /** @var \Pterodactyl\Transformers\Api\Client\BaseClientTransformer $transformer */ $transformer = Container::getInstance()->make($abstract); - Assert::isInstanceOf($transformer, BaseClientTransformer::class); + Assert::isInstanceOfAny($transformer, [ + BaseClientTransformer::class, + BaseDaemonTransformer::class, + ]); - $transformer->setKey($this->request->attributes->get('api_key')); - $transformer->setUser($this->request->user()); + if ($transformer instanceof BaseClientTransformer) { + $transformer->setKey($this->request->attributes->get('api_key')); + $transformer->setUser($this->request->user()); + } return $transformer; } diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index eac0cd6d..ac152760 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -7,10 +7,13 @@ use Ramsey\Uuid\Uuid; use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; +use GuzzleHttp\Exception\TransferException; +use Pterodactyl\Transformers\Daemon\FileObjectTransformer; use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Contracts\Config\Repository as ConfigRepository; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Contracts\Repository\Daemon\FileRepositoryInterface; +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; @@ -57,16 +60,23 @@ class FileController extends ClientApiController * Returns a listing of files in a given directory. * * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\ListFilesRequest $request - * @return \Illuminate\Http\JsonResponse + * @return array + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ - public function listDirectory(ListFilesRequest $request): JsonResponse + public function listDirectory(ListFilesRequest $request): array { - return JsonResponse::create([ - 'contents' => $this->fileRepository->setServer($request->getModel(Server::class))->getDirectory( - $request->get('directory') ?? '/' - ), - 'editable' => $this->config->get('pterodactyl.files.editable', []), - ]); + try { + $contents = $this->fileRepository + ->setServer($request->getModel(Server::class)) + ->getDirectory($request->get('directory') ?? '/'); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception, true); + } + + return $this->fractal->collection($contents) + ->transformWith($this->getTransformer(FileObjectTransformer::class)) + ->toArray(); } /** diff --git a/app/Transformers/Daemon/BaseDaemonTransformer.php b/app/Transformers/Daemon/BaseDaemonTransformer.php new file mode 100644 index 00000000..7ec75864 --- /dev/null +++ b/app/Transformers/Daemon/BaseDaemonTransformer.php @@ -0,0 +1,9 @@ +editable = config('pterodactyl.files.editable', []); + } + + /** + * Transform a file object response from the daemon into a standardized response. + * + * @param array $item + * @return array + */ + public function transform(array $item) + { + return [ + 'name' => Arr::get($item, 'name'), + 'mode' => Arr::get($item, 'mode'), + 'size' => Arr::get($item, 'size'), + 'is_file' => Arr::get($item, 'file', true), + 'is_symlink' => Arr::get($item, 'symlink', false), + 'is_editable' => in_array(Arr::get($item, 'mime', ''), $this->editable), + 'mimetype' => Arr::get($item, 'mime'), + 'created_at' => Carbon::parse(explode(' ', Arr::get($item, 'created', ''))[0] ?? '')->toIso8601String(), + 'modified_at' => Carbon::parse(explode(' ', Arr::get($item, 'modified', ''))[0] ?? '')->toIso8601String(), + ]; + } + + /** + * @return string + */ + public function getResourceName(): string + { + return 'file_object'; + } +} diff --git a/resources/scripts/api/server/files/loadDirectory.ts b/resources/scripts/api/server/files/loadDirectory.ts new file mode 100644 index 00000000..64e504e0 --- /dev/null +++ b/resources/scripts/api/server/files/loadDirectory.ts @@ -0,0 +1,33 @@ +import http from '@/api/http'; + +export interface FileObject { + name: string; + mode: string; + size: number; + isFile: boolean; + isSymlink: boolean; + isEditable: boolean; + mimetype: string; + createdAt: Date; + modifiedAt: Date; +} + +export default (uuid: string, directory?: string): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/client/servers/${uuid}/files/list`, { + params: { directory }, + }) + .then(response => resolve((response.data.data || []).map((item: any): FileObject => ({ + name: item.attributes.name, + mode: item.attributes.mode, + size: Number(item.attributes.size), + isFile: item.attributes.is_file, + isSymlink: item.attributes.is_symlink, + isEditable: item.attributes.is_editable, + mimetype: item.attributes.mimetype, + createdAt: new Date(item.attributes.created_at), + modifiedAt: new Date(item.attributes.modified_at), + })))) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx new file mode 100644 index 00000000..1f6c88a1 --- /dev/null +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useState } from 'react'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { ServerContext } from '@/state/server'; +import loadDirectory, { FileObject } from '@/api/server/files/loadDirectory'; +import { Actions, useStoreActions } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; +import { httpErrorToHuman } from '@/api/http'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faFolder } from '@fortawesome/free-solid-svg-icons/faFolder'; +import { faFileAlt } from '@fortawesome/free-solid-svg-icons/faFileAlt'; +import format from 'date-fns/format'; +import differenceInHours from 'date-fns/difference_in_hours'; +import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; +import { bytesToHuman } from '@/helpers'; +import { CSSTransition } from 'react-transition-group'; +import { Link } from 'react-router-dom'; +import Spinner from '@/components/elements/Spinner'; + +export default () => { + const [ loading, setLoading ] = useState(true); + const [ files, setFiles ] = useState([]); + const server = ServerContext.useStoreState(state => state.server.data!); + const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + + const currentDirectory = window.location.hash.replace(/^#(\/)+/, '/'); + + const load = () => { + setLoading(true); + clearFlashes(); + loadDirectory(server.uuid, currentDirectory) + .then(files => { + setFiles(files); + setLoading(false); + }) + .catch(error => { + if (error.response && error.response.status === 404) { + window.location.hash = '#/'; + return; + } + + console.error(error.message, { error }); + addError({ message: httpErrorToHuman(error), key: 'files' }); + }); + }; + + const breadcrumbs = (): { name: string; path?: string }[] => currentDirectory.split('/') + .filter(directory => !!directory) + .map((directory, index, dirs) => { + if (index === dirs.length - 1) { + return { name: directory }; + } + + return { name: directory, path: `/${dirs.slice(0, index + 1).join('/')}` }; + }); + + useEffect(() => { + load(); + }, [ window.location.hash ]); + + return ( +
+ + +
+ /home/ + + container + / + { + breadcrumbs().map((crumb, index) => ( + crumb.path ? + + + {crumb.name} + / + + : + {crumb.name} + )) + } +
+ { + loading ? + + : + !files.length ? +

+ This directory seems to be empty. +

+ : + + + + } +
+
+ ); +}; diff --git a/resources/scripts/helpers.ts b/resources/scripts/helpers.ts new file mode 100644 index 00000000..9cec33a0 --- /dev/null +++ b/resources/scripts/helpers.ts @@ -0,0 +1,6 @@ +export function bytesToHuman (bytes: number): string { + const i = Math.floor(Math.log(bytes) / Math.log(1000)); + + // @ts-ignore + return `${(bytes / Math.pow(1000, i)).toFixed(2) * 1} ${['Bytes', 'kB', 'MB', 'GB', 'TB'][i]}`; +}