diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index 410d58aa..a425a82f 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -247,15 +247,19 @@ class FileController extends ClientApiController /** * Updates file permissions for file(s) in the given root directory. * - * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + * @throws \Throwable */ public function chmod(ChmodFilesRequest $request, Server $server): Response { - $this->fileRepository->setServer($server) - ->chmodFiles( - $request->input('root'), - $request->input('files') - ); + $server->audit(AuditLog::SERVER__FILESYSTEM_CHMOD, function (AuditLog $audit, Server $server) use ($request) { + $audit->metadata = ['directory' => $request->input('root'), 'files' => $request->input('files')]; + + $this->fileRepository->setServer($server) + ->chmodFiles( + $request->input('root'), + $request->input('files'), + ); + }); return $this->returnNoContent(); } @@ -268,9 +272,13 @@ class FileController extends ClientApiController public function pull(PullFileRequest $request, Server $server): Response { $server->audit(AuditLog::SERVER__FILESYSTEM_PULL, function (AuditLog $audit, Server $server) use ($request) { - $audit->metadata = ['directory' => $request->input('directory'), 'url' => $request->input('url')]; + $audit->metadata = ['directory' => $request->input('root'), 'url' => $request->input('url')]; - $this->fileRepository->setServer($server)->pull($request->input('url'), $request->input('directory')); + $this->fileRepository->setServer($server) + ->pull( + $request->input('root'), + $request->input('url'), + ); }); return $this->returnNoContent(); diff --git a/app/Http/Requests/Api/Client/Servers/Files/PullFileRequest.php b/app/Http/Requests/Api/Client/Servers/Files/PullFileRequest.php index ad3d5789..cfcaf29b 100644 --- a/app/Http/Requests/Api/Client/Servers/Files/PullFileRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Files/PullFileRequest.php @@ -19,8 +19,8 @@ class PullFileRequest extends ClientApiRequest implements ClientPermissionsReque public function rules(): array { return [ + 'root' => 'sometimes|nullable|string', 'url' => 'required|string|url', - 'directory' => 'sometimes|nullable|string', ]; } } diff --git a/app/Http/ViewComposers/AssetComposer.php b/app/Http/ViewComposers/AssetComposer.php index 32ec9fb8..c84f2657 100644 --- a/app/Http/ViewComposers/AssetComposer.php +++ b/app/Http/ViewComposers/AssetComposer.php @@ -34,6 +34,9 @@ class AssetComposer 'siteKey' => config('recaptcha.website_key') ?? '', ], 'analytics' => config('app.analytics') ?? '', + 'features' => [ + 'pullFiles' => config('features.pull_files'), + ], ]); } } diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php index 5f2a334b..18b466a2 100644 --- a/app/Models/AuditLog.php +++ b/app/Models/AuditLog.php @@ -30,6 +30,7 @@ class AuditLog extends Model public const SERVER__FILESYSTEM_RENAME = 'server:filesystem.rename'; public const SERVER__FILESYSTEM_COMPRESS = 'server:filesystem.compress'; public const SERVER__FILESYSTEM_DECOMPRESS = 'server:filesystem.decompress'; + public const SERVER__FILESYSTEM_CHMOD = 'server:filesystem.chmod'; public const SERVER__FILESYSTEM_PULL = 'server:filesystem.pull'; public const SERVER__BACKUP_STARTED = 'server:backup.started'; public const SERVER__BACKUP_FAILED = 'server:backup.failed'; diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 0ea33b5d..e6b84ec8 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -3,6 +3,8 @@ namespace Pterodactyl\Providers; use Illuminate\Support\Facades\Route; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; class RouteServiceProvider extends ServiceProvider @@ -55,5 +57,9 @@ class RouteServiceProvider extends ServiceProvider Route::middleware(['daemon'])->prefix('/api/remote') ->namespace($this->namespace . '\Api\Remote') ->group(base_path('routes/api-remote.php')); + + RateLimiter::for('pull', function () { + return Limit::perMinute(10); + }); } } diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index f1acdc7d..ce189715 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -29,7 +29,7 @@ class DaemonFileRepository extends DaemonRepository sprintf('/api/servers/%s/files/contents', $this->server->uuid), [ 'query' => ['file' => $path], - ] + ], ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); @@ -60,7 +60,7 @@ class DaemonFileRepository extends DaemonRepository [ 'query' => ['file' => $path], 'body' => $content, - ] + ], ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); @@ -81,7 +81,7 @@ class DaemonFileRepository extends DaemonRepository sprintf('/api/servers/%s/files/list-directory', $this->server->uuid), [ 'query' => ['directory' => $path], - ] + ], ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); @@ -107,7 +107,7 @@ class DaemonFileRepository extends DaemonRepository 'name' => $name, 'path' => $path, ], - ] + ], ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); @@ -131,7 +131,7 @@ class DaemonFileRepository extends DaemonRepository 'root' => $root ?? '/', 'files' => $files, ], - ] + ], ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); @@ -154,7 +154,7 @@ class DaemonFileRepository extends DaemonRepository 'json' => [ 'location' => $location, ], - ] + ], ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); @@ -178,7 +178,7 @@ class DaemonFileRepository extends DaemonRepository 'root' => $root ?? '/', 'files' => $files, ], - ] + ], ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); @@ -205,7 +205,7 @@ class DaemonFileRepository extends DaemonRepository // 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) { throw new DaemonConnectionException($exception); @@ -231,7 +231,7 @@ class DaemonFileRepository extends DaemonRepository 'root' => $root ?? '/', 'file' => $file, ], - ] + ], ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); @@ -255,7 +255,7 @@ class DaemonFileRepository extends DaemonRepository 'root' => $root ?? '/', 'files' => $files, ], - ] + ], ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); @@ -267,7 +267,7 @@ class DaemonFileRepository extends DaemonRepository * * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ - public function pull(string $url, ?string $directory): ResponseInterface + public function pull(?string $root, string $url): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); @@ -275,8 +275,11 @@ class DaemonFileRepository extends DaemonRepository return $this->getHttpClient()->post( sprintf('/api/servers/%s/files/pull', $this->server->uuid), [ - 'json' => ['url' => $url, 'directory' => $directory ?? '/'], - ] + 'json' => [ + 'root' => $root ?? '/', + 'url' => $url, + ], + ], ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); diff --git a/config/features.php b/config/features.php new file mode 100644 index 00000000..95c2c400 --- /dev/null +++ b/config/features.php @@ -0,0 +1,5 @@ + env('FEATURES_PULL_FILES', true), +]; diff --git a/resources/scripts/api/server/files/pullFile.ts b/resources/scripts/api/server/files/pullFile.ts new file mode 100644 index 00000000..f7a021fe --- /dev/null +++ b/resources/scripts/api/server/files/pullFile.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (uuid: string, directory: string, url: string): Promise => { + return new Promise((resolve, reject) => { + http.post(`/api/client/servers/${uuid}/files/pull`, { root: directory, url }) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/server/files/ChmodFileModal.tsx b/resources/scripts/components/server/files/ChmodFileModal.tsx index a5b5acc0..aa0a4413 100644 --- a/resources/scripts/components/server/files/ChmodFileModal.tsx +++ b/resources/scripts/components/server/files/ChmodFileModal.tsx @@ -15,8 +15,8 @@ interface FormikValues { } interface File { - file: string, - mode: string, + file: string; + mode: string; } type OwnProps = RequiredModalProps & { files: File[] }; diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index 0e5b1064..2362fcb1 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -16,10 +16,13 @@ import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import MassActionsBar from '@/components/server/files/MassActionsBar'; import UploadButton from '@/components/server/files/UploadButton'; import ServerContentBlock from '@/components/elements/ServerContentBlock'; -import { useStoreActions } from '@/state/hooks'; import ErrorBoundary from '@/components/elements/ErrorBoundary'; +import PullFileModal from '@/components/server/files/PullFileModal'; import { FileActionCheckbox } from '@/components/server/files/SelectFileCheckbox'; import { hashToPath } from '@/helpers'; +import { useStoreActions } from '@/state/hooks'; +import { useStoreState } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; const sortFiles = (files: FileObject[]): FileObject[] => { return files.sort((a, b) => a.name.localeCompare(b.name)) @@ -27,16 +30,20 @@ const sortFiles = (files: FileObject[]): FileObject[] => { }; export default () => { + const pullFiles = useStoreState((state: ApplicationStore) => state.settings.data!.features.pullFiles); + const id = ServerContext.useStoreState(state => state.server.data!.id); - const { hash } = useLocation(); - const { data: files, error, mutate } = useFileManagerSwr(); const directory = ServerContext.useStoreState(state => state.files.directory); - const clearFlashes = useStoreActions(actions => actions.flashes.clearFlashes); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles); const selectedFilesLength = ServerContext.useStoreState(state => state.files.selectedFiles.length); + const clearFlashes = useStoreActions(actions => actions.flashes.clearFlashes); + + const { hash } = useLocation(); + const { data: files, error, mutate } = useFileManagerSwr(); + useEffect(() => { clearFlashes('files'); setSelectedFiles([]); @@ -75,6 +82,7 @@ export default () => {
+ {pullFiles && } { validationSchema={object().shape({ directoryName: string() .required('A valid directory name must be provided.') - .test('unique', 'Directory with that name already exists.', v => { + .test('unique', 'File or directory with that name already exists.', v => { return v !== undefined && data !== undefined && data.filter(f => f.name.toLowerCase() === v.toLowerCase()).length < 1; diff --git a/resources/scripts/components/server/files/PullFileModal.tsx b/resources/scripts/components/server/files/PullFileModal.tsx new file mode 100644 index 00000000..ecb4667d --- /dev/null +++ b/resources/scripts/components/server/files/PullFileModal.tsx @@ -0,0 +1,121 @@ +import { Form, Formik, FormikHelpers } from 'formik'; +import React, { useEffect, useState } from 'react'; +import tw from 'twin.macro'; +import { object, string } from 'yup'; +import pullFile from '@/api/server/files/pullFile'; +import { WithClassname } from '@/components/types'; +import Button from '@/components/elements/Button'; +import Field from '@/components/elements/Field'; +import Modal from '@/components/elements/Modal'; +import useFileManagerSwr from '@/plugins/useFileManagerSwr'; +import useFlash from '@/plugins/useFlash'; +import { ServerContext } from '@/state/server'; +import { FileObject } from '@/api/server/files/loadDirectory'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { join } from 'path'; + +interface Values { + url: string; +} + +const generateFileData = (name: string): FileObject => ({ + key: `file_${name.split('/', 1)[0] ?? name}`, + name: name, + mode: 'rw-rw-rw-', + modeBits: '0644', + size: 0, + isFile: true, + isSymlink: false, + mimetype: '', + createdAt: new Date(), + modifiedAt: new Date(), + isArchiveType: () => false, + isEditable: () => false, +}); + +export default ({ className }: WithClassname) => { + const [ visible, setVisible ] = useState(false); + + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const directory = ServerContext.useStoreState(state => state.files.directory); + + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const { data, mutate } = useFileManagerSwr(); + + useEffect(() => { + if (!visible) return; + + return () => { + clearFlashes('files:pull-modal'); + }; + }, [ visible ]); + + const submit = ({ url }: Values, { setSubmitting }: FormikHelpers) => { + pullFile(uuid, directory, url) + .then(() => mutate(data => [ ...data!, generateFileData(new URL(url).pathname.split('/').pop() || '') ], false)) + .then(() => setVisible(false)) + .catch(error => { + console.error(error); + setSubmitting(false); + clearAndAddHttpError({ key: 'files:pull-modal', error }); + }); + }; + + return ( + <> + { + return v !== undefined && + data !== undefined && + data.filter(f => f.name.toLowerCase() === v.toLowerCase()).length < 1; + }), + })} + > + {({ resetForm, isSubmitting, values }) => ( + { + setVisible(false); + resetForm(); + }} + > + +
+ +

+ This file will be downloaded to +  /home/container/ + + {values.url !== '' ? join(directory, new URL(values.url).pathname.split('/').pop() || '').substr(1) : ''} + +

+
+ +
+ +
+ )} +
+ + + ); +}; diff --git a/resources/scripts/state/settings.ts b/resources/scripts/state/settings.ts index 3eb782d9..101efe8f 100644 --- a/resources/scripts/state/settings.ts +++ b/resources/scripts/state/settings.ts @@ -8,6 +8,9 @@ export interface SiteSettings { siteKey: string; }; analytics: string; + features: { + pullFiles: boolean; + }; } export interface SettingsStore { diff --git a/routes/api-client.php b/routes/api-client.php index f9d33555..755cabde 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -5,6 +5,8 @@ use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication; use Pterodactyl\Http\Middleware\Api\Client\Server\ResourceBelongsToServer; use Pterodactyl\Http\Middleware\Api\Client\Server\AuthenticateServerAccess; +use Pterodactyl\Http\Controllers\Api\Client; + /* |-------------------------------------------------------------------------- | Client Control API @@ -63,19 +65,19 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ }); Route::group(['prefix' => '/files'], function () { - Route::get('/list', 'Servers\FileController@directory'); - Route::get('/contents', 'Servers\FileController@contents'); - Route::get('/download', 'Servers\FileController@download'); - Route::put('/rename', 'Servers\FileController@rename'); - 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'); - Route::post('/chmod', 'Servers\FileController@chmod'); - Route::post('/pull', 'Servers\FileController@pull')->middleware(['throttle:10,5']); - Route::get('/upload', 'Servers\FileUploadController'); + Route::get('/list', [Client\Servers\FileController::class, 'directory']); + Route::get('/contents', [Client\Servers\FileController::class, 'contents']); + Route::get('/download', [Client\Servers\FileController::class, 'download']); + Route::put('/rename', [Client\Servers\FileController::class, 'rename']); + Route::post('/copy', [Client\Servers\FileController::class, 'copy']); + Route::post('/write', [Client\Servers\FileController::class, 'write']); + Route::post('/compress', [Client\Servers\FileController::class, 'compress']); + Route::post('/decompress', [Client\Servers\FileController::class, 'decompress']); + Route::post('/delete', [Client\Servers\FileController::class, 'delete']); + Route::post('/create-folder', [Client\Servers\FileController::class, 'create']); + Route::post('/chmod', [Client\Servers\FileController::class, 'chmod']); + Route::post('/pull', [Client\Servers\FileController::class, 'pull'])->middleware(['throttle:pull']); + Route::get('/upload', [Client\Servers\FileUploadController::class]); }); Route::group(['prefix' => '/schedules'], function () {