diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index 9e705651b..0e3a62f2e 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -16,6 +16,7 @@ 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; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Files\ChmodFilesRequest; 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; @@ -263,6 +264,25 @@ class FileController extends ClientApiController return new JsonResponse([], Response::HTTP_NO_CONTENT); } + /** + * Updates file permissions for file(s) in the given root directory. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\ChmodFilesRequest $request + * @param \Pterodactyl\Models\Server $server + * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function chmod(ChmodFilesRequest $request, Server $server): JsonResponse + { + $this->fileRepository->setServer($server) + ->chmodFiles( + $request->input('root'), $request->input('files') + ); + + return new JsonResponse([], Response::HTTP_NO_CONTENT); + } + /** * Encodes a given file name & path in a format that should work for a good majority * of file names without too much confusing logic. diff --git a/app/Http/Requests/Api/Client/Servers/Files/ChmodFilesRequest.php b/app/Http/Requests/Api/Client/Servers/Files/ChmodFilesRequest.php new file mode 100644 index 000000000..158a0a7fd --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Files/ChmodFilesRequest.php @@ -0,0 +1,31 @@ + 'required|nullable|string', + 'files' => 'required|array', + 'files.*.file' => 'required|string', + 'files.*.mode' => 'required|numeric', + ]; + } +} diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index 1ae424585..c36a8abb0 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -269,4 +269,32 @@ class DaemonFileRepository extends DaemonRepository throw new DaemonConnectionException($exception); } } + + /** + * Chmods the given files. + * + * @param string|null $root + * @param array $files + * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function chmodFiles(?string $root, array $files): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/chmod', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } } diff --git a/app/Transformers/Daemon/FileObjectTransformer.php b/app/Transformers/Daemon/FileObjectTransformer.php index 84fcaf2d4..f19d9028c 100644 --- a/app/Transformers/Daemon/FileObjectTransformer.php +++ b/app/Transformers/Daemon/FileObjectTransformer.php @@ -25,6 +25,7 @@ class FileObjectTransformer extends BaseDaemonTransformer return [ 'name' => Arr::get($item, 'name'), 'mode' => Arr::get($item, 'mode'), + 'mode_bits' => Arr::get($item, 'mode_bits'), 'size' => Arr::get($item, 'size'), 'is_file' => Arr::get($item, 'file', true), 'is_symlink' => Arr::get($item, 'symlink', false), diff --git a/resources/scripts/api/server/files/chmodFiles.ts b/resources/scripts/api/server/files/chmodFiles.ts new file mode 100644 index 000000000..8bafd6d6e --- /dev/null +++ b/resources/scripts/api/server/files/chmodFiles.ts @@ -0,0 +1,14 @@ +import http from '@/api/http'; + +interface Data { + file: string; + mode: string; +} + +export default (uuid: string, directory: string, files: Data[]): Promise => { + return new Promise((resolve, reject) => { + http.post(`/api/client/servers/${uuid}/files/chmod`, { root: directory, files }) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/server/files/loadDirectory.ts b/resources/scripts/api/server/files/loadDirectory.ts index d29cc1605..52bf8853e 100644 --- a/resources/scripts/api/server/files/loadDirectory.ts +++ b/resources/scripts/api/server/files/loadDirectory.ts @@ -5,6 +5,7 @@ export interface FileObject { key: string; name: string; mode: string; + modeBits: string, size: number; isFile: boolean; isSymlink: boolean; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index f17787e03..e08c00076 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -16,6 +16,7 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({ key: `${data.attributes.is_file ? 'file' : 'dir'}_${data.attributes.name}`, name: data.attributes.name, mode: data.attributes.mode, + modeBits: data.attributes.mode_bits, size: Number(data.attributes.size), isFile: data.attributes.is_file, isSymlink: data.attributes.is_symlink, diff --git a/resources/scripts/components/server/files/ChmodFileModal.tsx b/resources/scripts/components/server/files/ChmodFileModal.tsx new file mode 100644 index 000000000..a7997469b --- /dev/null +++ b/resources/scripts/components/server/files/ChmodFileModal.tsx @@ -0,0 +1,75 @@ +import { fileBitsToString } from '@/helpers'; +import useFileManagerSwr from '@/plugins/useFileManagerSwr'; +import React from 'react'; +import Modal, { RequiredModalProps } from '@/components/elements/Modal'; +import { Form, Formik, FormikHelpers } from 'formik'; +import Field from '@/components/elements/Field'; +import chmodFiles from '@/api/server/files/chmodFiles'; +import { ServerContext } from '@/state/server'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import useFlash from '@/plugins/useFlash'; + +interface FormikValues { + mode: string; +} + +interface File { + file: string, + mode: string, +} + +type OwnProps = RequiredModalProps & { files: File[] }; + +const ChmodFileModal = ({ files, ...props }: OwnProps) => { + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + 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 = ({ mode }: FormikValues, { setSubmitting }: FormikHelpers) => { + clearFlashes('files'); + + mutate(data => data.map(f => f.name === files[0].file ? { ...f, mode: fileBitsToString(mode, !f.isFile), modeBits: mode } : f), false); + + const data = files.map(f => ({ file: f.file, mode: mode })); + + chmodFiles(uuid, directory, data) + .then((): Promise => files.length > 0 ? mutate() : Promise.resolve()) + .then(() => setSelectedFiles([])) + .catch(error => { + mutate(); + setSubmitting(false); + clearAndAddHttpError({ key: 'files', error }); + }) + .then(() => props.onDismissed()); + }; + + return ( + 1 ? '' : (files[0].mode || '') }}> + {({ isSubmitting }) => ( + +
+
+
+ +
+
+ +
+
+
+
+ )} +
+ ); +}; + +export default ChmodFileModal; diff --git a/resources/scripts/components/server/files/FileDropdownMenu.tsx b/resources/scripts/components/server/files/FileDropdownMenu.tsx index 48d53eeb2..603ed80ea 100644 --- a/resources/scripts/components/server/files/FileDropdownMenu.tsx +++ b/resources/scripts/components/server/files/FileDropdownMenu.tsx @@ -5,6 +5,7 @@ import { faCopy, faEllipsisH, faFileArchive, + faFileCode, faFileDownload, faLevelUpAlt, faPencilAlt, @@ -30,8 +31,9 @@ import compressFiles from '@/api/server/files/compressFiles'; import decompressFiles from '@/api/server/files/decompressFiles'; import isEqual from 'react-fast-compare'; import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import ChmodFileModal from '@/components/server/files/ChmodFileModal'; -type ModalType = 'rename' | 'move'; +type ModalType = 'rename' | 'move' | 'chmod'; const StyledRow = styled.div<{ $danger?: boolean }>` ${tw`p-2 flex items-center rounded`}; @@ -140,14 +142,23 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => { renderToggle={onClick => (
- {!!modal && - setModal(null)} - /> + {modal ? + modal === 'chmod' ? + setModal(null)} + /> + : + setModal(null)} + /> + : null }
@@ -156,6 +167,7 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => { setModal('rename')} icon={faPencilAlt} title={'Rename'}/> setModal('move')} icon={faLevelUpAlt} title={'Move'}/> + setModal('chmod')} icon={faFileCode} title={'Permissions'}/> {file.isFile && diff --git a/resources/scripts/components/server/files/FileObjectRow.tsx b/resources/scripts/components/server/files/FileObjectRow.tsx index fccef1ec6..89a080759 100644 --- a/resources/scripts/components/server/files/FileObjectRow.tsx +++ b/resources/scripts/components/server/files/FileObjectRow.tsx @@ -64,7 +64,11 @@ const FileObjectRow = ({ file }: { file: FileObject }) => ( > -
+ + +
{file.isFile ? : diff --git a/resources/scripts/components/server/files/NewDirectoryButton.tsx b/resources/scripts/components/server/files/NewDirectoryButton.tsx index dc91377bc..56b58e17e 100644 --- a/resources/scripts/components/server/files/NewDirectoryButton.tsx +++ b/resources/scripts/components/server/files/NewDirectoryButton.tsx @@ -25,7 +25,8 @@ const schema = object().shape({ const generateDirectoryData = (name: string): FileObject => ({ key: `dir_${name.split('/', 1)[0] ?? name}`, name: name.replace(/^(\/*)/, '').split('/', 1)[0] ?? name, - mode: '0644', + mode: 'drwxr-xr-x', + modeBits: '0755', size: 0, isFile: false, isSymlink: false, diff --git a/resources/scripts/helpers.ts b/resources/scripts/helpers.ts index 84358193b..fdeef7ecd 100644 --- a/resources/scripts/helpers.ts +++ b/resources/scripts/helpers.ts @@ -20,3 +20,33 @@ export const randomInt = (low: number, high: number) => Math.floor(Math.random() export const cleanDirectoryPath = (path: string) => path.replace(/(^#\/*)|(\/(\/*))|(^$)/g, '/'); export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); + +export function fileBitsToString (mode: string, directory: boolean): string { + const m = parseInt(mode, 8); + + let buf = ''; + 'dalTLDpSugct?'.split('').forEach((c, i) => { + if ((m & (1 << (32 - 1 - i))) !== 0) { + buf = buf + c; + } + }); + + if (buf.length === 0) { + // If the file is directory, make sure it has the directory flag. + if (directory) { + buf = 'd'; + } else { + buf = '-'; + } + } + + 'rwxrwxrwx'.split('').forEach((c, i) => { + if ((m & (1 << (9 - 1 - i))) !== 0) { + buf = buf + c; + } else { + buf = buf + '-'; + } + }); + + return buf; +} diff --git a/routes/api-client.php b/routes/api-client.php index 170828be1..35a2938dd 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -64,6 +64,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ 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::get('/upload', 'Servers\FileUploadController'); });