Support modifying startup variables for servers

This commit is contained in:
Dane Everitt 2020-08-22 18:13:59 -07:00
parent 1b69d82daa
commit 91cdbd6c2e
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
11 changed files with 226 additions and 6 deletions

View file

@ -0,0 +1,81 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Services\Servers\VariableValidatorService;
use Pterodactyl\Repositories\Eloquent\ServerVariableRepository;
use Pterodactyl\Transformers\Api\Client\EggVariableTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Startup\UpdateStartupVariableRequest;
class StartupController extends ClientApiController
{
/**
* @var \Pterodactyl\Services\Servers\VariableValidatorService
*/
private $service;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerVariableRepository
*/
private $repository;
/**
* StartupController constructor.
*
* @param \Pterodactyl\Services\Servers\VariableValidatorService $service
* @param \Pterodactyl\Repositories\Eloquent\ServerVariableRepository $repository
*/
public function __construct(VariableValidatorService $service, ServerVariableRepository $repository)
{
parent::__construct();
$this->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();
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Startup;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class UpdateStartupVariableRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_STARTUP_UPDATE;
}
/**
* The actual validation of the variable's value will happen inside the controller.
*
* @return array|string[]
*/
public function rules(): array
{
return [
'key' => 'required|string',
'value' => 'present|string',
];
}
}

View file

@ -55,6 +55,9 @@ class Permission extends Model
const ACTION_FILE_ARCHIVE = 'file.archive'; const ACTION_FILE_ARCHIVE = 'file.archive';
const ACTION_FILE_SFTP = 'file.sftp'; 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_RENAME = 'settings.rename';
const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
@ -169,8 +172,8 @@ class Permission extends Model
'startup' => [ 'startup' => [
'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.', 'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
'keys' => [ 'keys' => [
'read' => '', 'read' => 'Allows a user to view the startup variables for a server.',
'update' => '', 'update' => 'Allows a user to modify the startup variables for the server.',
], ],
], ],

View file

@ -39,6 +39,8 @@ rules:
comma-dangle: comma-dangle:
- warn - warn
- always-multiline - always-multiline
spaced-comment:
- warn
array-bracket-spacing: array-bracket-spacing:
- warn - warn
- always - always

View file

@ -1,5 +1,6 @@
import http, { FractalResponseData, FractalResponseList } from '@/api/http'; 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 { export interface Allocation {
id: number; id: number;
@ -21,7 +22,6 @@ export interface Server {
}; };
invocation: string; invocation: string;
description: string; description: string;
allocations: Allocation[];
limits: { limits: {
memory: number; memory: number;
swap: number; swap: number;
@ -37,6 +37,8 @@ export interface Server {
}; };
isSuspended: boolean; isSuspended: boolean;
isInstalling: boolean; isInstalling: boolean;
variables: ServerEggVariable[];
allocations: Allocation[];
} }
export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
@ -54,6 +56,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
featureLimits: { ...data.feature_limits }, featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended, isSuspended: data.is_suspended,
isInstalling: data.is_installing, isInstalling: data.is_installing,
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable),
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation), allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
}); });

View file

@ -8,3 +8,13 @@ export interface ServerBackup {
createdAt: Date; createdAt: Date;
completedAt: Date | null; completedAt: Date | null;
} }
export interface ServerEggVariable {
name: string;
description: string;
envVariable: string;
defaultValue: string;
serverValue: string;
isEditable: boolean;
rules: string[];
}

View file

@ -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<ServerEggVariable> => {
const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value });
return rawDataToServerEggVariable(data);
};

View file

@ -1,7 +1,7 @@
import { Allocation } from '@/api/server/getServer'; import { Allocation } from '@/api/server/getServer';
import { FractalResponseData } from '@/api/http'; import { FractalResponseData } from '@/api/http';
import { FileObject } from '@/api/server/files/loadDirectory'; 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 => ({ export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({
id: data.attributes.id, id: data.attributes.id,
@ -51,3 +51,13 @@ export const rawDataToServerBackup = ({ attributes }: FractalResponseData): Serv
createdAt: new Date(attributes.created_at), createdAt: new Date(attributes.created_at),
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null, 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('|'),
});

View file

@ -3,9 +3,10 @@ import PageContentBlock from '@/components/elements/PageContentBlock';
import TitledGreyBox from '@/components/elements/TitledGreyBox'; import TitledGreyBox from '@/components/elements/TitledGreyBox';
import useServer from '@/plugins/useServer'; import useServer from '@/plugins/useServer';
import tw from 'twin.macro'; import tw from 'twin.macro';
import VariableBox from '@/components/server/startup/VariableBox';
const StartupContainer = () => { const StartupContainer = () => {
const { invocation } = useServer(); const { invocation, variables } = useServer();
return ( return (
<PageContentBlock title={'Startup Settings'} showFlashKey={'server:startup'}> <PageContentBlock title={'Startup Settings'} showFlashKey={'server:startup'}>
@ -16,6 +17,9 @@ const StartupContainer = () => {
</p> </p>
</div> </div>
</TitledGreyBox> </TitledGreyBox>
<div css={tw`grid gap-8 grid-cols-2 mt-10`}>
{variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
</div>
</PageContentBlock> </PageContentBlock>
); );
}; };

View file

@ -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 (
<TitledGreyBox title={variable.name}>
<FlashMessageRender byKey={FLASH_KEY} css={tw`mb-4`}/>
<InputSpinner visible={loading}>
<Input
onKeyUp={e => setVariableValue(e.currentTarget.value)}
readOnly={!canEdit}
name={variable.envVariable}
defaultValue={variable.serverValue}
placeholder={variable.defaultValue}
/>
</InputSpinner>
<p css={tw`mt-1 text-xs text-neutral-400`}>
{variable.description}
</p>
</TitledGreyBox>
);
};
export default VariableBox;

View file

@ -101,6 +101,10 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
Route::delete('/{backup}', 'Servers\BackupController@delete'); Route::delete('/{backup}', 'Servers\BackupController@delete');
}); });
Route::group(['prefix' => '/startup'], function () {
Route::put('/variable', 'Servers\StartupController@update');
});
Route::group(['prefix' => '/settings'], function () { Route::group(['prefix' => '/settings'], function () {
Route::post('/rename', 'Servers\SettingsController@rename'); Route::post('/rename', 'Servers\SettingsController@rename');
Route::post('/reinstall', 'Servers\SettingsController@reinstall'); Route::post('/reinstall', 'Servers\SettingsController@reinstall');