diff --git a/app/Helpers/Utilities.php b/app/Helpers/Utilities.php index 5de685fe9..2a0689124 100644 --- a/app/Helpers/Utilities.php +++ b/app/Helpers/Utilities.php @@ -3,6 +3,8 @@ namespace Pterodactyl\Helpers; use Exception; +use Carbon\Carbon; +use Cron\CronExpression; use Illuminate\Support\Facades\Log; class Utilities @@ -32,4 +34,20 @@ class Utilities return $string; } + + /** + * Converts schedule cron data into a carbon object. + * + * @param string $minute + * @param string $hour + * @param string $dayOfMonth + * @param string $dayOfWeek + * @return \Carbon\Carbon + */ + public static function getScheduleNextRunDate(string $minute, string $hour, string $dayOfMonth, string $dayOfWeek) + { + return Carbon::instance(CronExpression::factory( + sprintf('%s %s %s * %s', $minute, $hour, $dayOfMonth, $dayOfWeek) + )->getNextRunDate()); + } } diff --git a/app/Http/Controllers/Api/Client/Servers/ScheduleController.php b/app/Http/Controllers/Api/Client/Servers/ScheduleController.php index 42e0a03fa..d1a6b9f4f 100644 --- a/app/Http/Controllers/Api/Client/Servers/ScheduleController.php +++ b/app/Http/Controllers/Api/Client/Servers/ScheduleController.php @@ -2,23 +2,51 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; +use Exception; +use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Pterodactyl\Models\Schedule; +use Illuminate\Http\JsonResponse; +use Pterodactyl\Helpers\Utilities; +use Pterodactyl\Exceptions\DisplayException; +use Pterodactyl\Repositories\Eloquent\ScheduleRepository; use Pterodactyl\Transformers\Api\Client\ScheduleTransformer; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\ViewScheduleRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreScheduleRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\DeleteScheduleRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\UpdateScheduleRequest; class ScheduleController extends ClientApiController { + /** + * @var \Pterodactyl\Repositories\Eloquent\ScheduleRepository + */ + private $repository; + + /** + * ScheduleController constructor. + * + * @param \Pterodactyl\Repositories\Eloquent\ScheduleRepository $repository + */ + public function __construct(ScheduleRepository $repository) + { + parent::__construct(); + + $this->repository = $repository; + } + /** * Returns all of the schedules belonging to a given server. * - * @param \Illuminate\Http\Request $request + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\ViewScheduleRequest $request * @param \Pterodactyl\Models\Server $server * @return array */ - public function index(Request $request, Server $server) + public function index(ViewScheduleRequest $request, Server $server) { $schedules = $server->schedule; $schedules->loadMissing('tasks'); @@ -28,15 +56,44 @@ class ScheduleController extends ClientApiController ->toArray(); } + /** + * Store a new schedule for a server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\StoreScheduleRequest $request + * @param \Pterodactyl\Models\Server $server + * @return array + * + * @throws \Pterodactyl\Exceptions\DisplayException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + */ + public function store(StoreScheduleRequest $request, Server $server) + { + /** @var \Pterodactyl\Models\Schedule $model */ + $model = $this->repository->create([ + 'server_id' => $server->id, + 'name' => $request->input('name'), + 'cron_day_of_week' => $request->input('day_of_week'), + 'cron_day_of_month' => $request->input('day_of_month'), + 'cron_hour' => $request->input('hour'), + 'cron_minute' => $request->input('minute'), + 'is_active' => (bool) $request->input('is_active'), + 'next_run_at' => $this->getNextRunAt($request), + ]); + + return $this->fractal->item($model) + ->transformWith($this->getTransformer(ScheduleTransformer::class)) + ->toArray(); + } + /** * Returns a specific schedule for the server. * - * @param \Illuminate\Http\Request $request + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\ViewScheduleRequest $request * @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Schedule $schedule * @return array */ - public function view(Request $request, Server $server, Schedule $schedule) + public function view(ViewScheduleRequest $request, Server $server, Schedule $schedule) { if ($schedule->server_id !== $server->id) { throw new NotFoundHttpException; @@ -48,4 +105,71 @@ class ScheduleController extends ClientApiController ->transformWith($this->getTransformer(ScheduleTransformer::class)) ->toArray(); } + + /** + * Updates a given schedule with the new data provided. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\UpdateScheduleRequest $request + * @param \Pterodactyl\Models\Server $server + * @param \Pterodactyl\Models\Schedule $schedule + * @return array + * + * @throws \Pterodactyl\Exceptions\DisplayException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function update(UpdateScheduleRequest $request, Server $server, Schedule $schedule) + { + $this->repository->update($schedule->id, [ + 'name' => $request->input('name'), + 'cron_day_of_week' => $request->input('day_of_week'), + 'cron_day_of_month' => $request->input('day_of_month'), + 'cron_hour' => $request->input('hour'), + 'cron_minute' => $request->input('minute'), + 'is_active' => (bool) $request->input('is_active'), + 'next_run_at' => $this->getNextRunAt($request), + ]); + + return $this->fractal->item($schedule->refresh()) + ->transformWith($this->getTransformer(ScheduleTransformer::class)) + ->toArray(); + } + + /** + * Deletes a schedule and it's associated tasks. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Schedules\DeleteScheduleRequest $request + * @param \Pterodactyl\Models\Server $server + * @param \Pterodactyl\Models\Schedule $schedule + * @return \Illuminate\Http\JsonResponse + */ + public function delete(DeleteScheduleRequest $request, Server $server, Schedule $schedule) + { + $this->repository->delete($schedule->id); + + return JsonResponse::create([], Response::HTTP_NO_CONTENT); + } + + /** + * Get the next run timestamp based on the cron data provided. + * + * @param \Illuminate\Http\Request $request + * @return \Carbon\Carbon + * @throws \Pterodactyl\Exceptions\DisplayException + */ + protected function getNextRunAt(Request $request): Carbon + { + try { + return Utilities::getScheduleNextRunDate( + $request->input('minute'), + $request->input('hour'), + $request->input('day_of_month'), + $request->input('day_of_week') + ); + } catch (Exception $exception) { + throw new DisplayException( + 'The cron data provided does not evaluate to a valid expression.' + ); + } + } } diff --git a/app/Http/Requests/Api/Client/ClientApiRequest.php b/app/Http/Requests/Api/Client/ClientApiRequest.php index ea9688bc9..465d5a48d 100644 --- a/app/Http/Requests/Api/Client/ClientApiRequest.php +++ b/app/Http/Requests/Api/Client/ClientApiRequest.php @@ -2,7 +2,6 @@ namespace Pterodactyl\Http\Requests\Api\Client; -use Pterodactyl\Models\User; use Pterodactyl\Models\Server; use Pterodactyl\Contracts\Http\ClientPermissionsRequest; use Pterodactyl\Http\Requests\Api\Application\ApplicationApiRequest; @@ -20,7 +19,15 @@ class ClientApiRequest extends ApplicationApiRequest public function authorize(): bool { if ($this instanceof ClientPermissionsRequest || method_exists($this, 'permission')) { - return $this->user()->can($this->permission(), $this->getModel(Server::class)); + $server = $this->route()->parameter('server'); + + if ($server instanceof Server) { + return $this->user()->can($this->permission(), $server); + } + + // If there is no server available on the reqest, trigger a failure since + // we expect there to be one at this point. + return false; } return true; diff --git a/app/Http/Requests/Api/Client/Servers/Schedules/DeleteScheduleRequest.php b/app/Http/Requests/Api/Client/Servers/Schedules/DeleteScheduleRequest.php new file mode 100644 index 000000000..8ecc7af0e --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Schedules/DeleteScheduleRequest.php @@ -0,0 +1,16 @@ + 'required|string|min:1', + 'is_active' => 'boolean', + 'minute' => 'required|string', + 'hour' => 'required|string', + 'day_of_month' => 'required|string', + 'day_of_week' => 'required|string', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Servers/Schedules/StoreTaskRequest.php b/app/Http/Requests/Api/Client/Servers/Schedules/StoreTaskRequest.php index 990102e74..86ec50f67 100644 --- a/app/Http/Requests/Api/Client/Servers/Schedules/StoreTaskRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Schedules/StoreTaskRequest.php @@ -3,9 +3,8 @@ namespace Pterodactyl\Http\Requests\Api\Client\Servers\Schedules; use Pterodactyl\Models\Permission; -use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest; -class StoreTaskRequest extends ClientApiRequest +class StoreTaskRequest extends ViewScheduleRequest { /** * Determine if the user is allowed to create a new task for this schedule. We simply @@ -14,7 +13,7 @@ class StoreTaskRequest extends ClientApiRequest * * @return string */ - public function permission() + public function permission(): string { return Permission::ACTION_SCHEDULE_UPDATE; } diff --git a/app/Http/Requests/Api/Client/Servers/Schedules/UpdateScheduleRequest.php b/app/Http/Requests/Api/Client/Servers/Schedules/UpdateScheduleRequest.php new file mode 100644 index 000000000..844388e29 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Schedules/UpdateScheduleRequest.php @@ -0,0 +1,16 @@ +route()->parameter('server'); + $schedule = $this->route()->parameter('schedule'); + + // If the schedule does not belong to this server throw a 404 error. Also throw an + // error if the task being requested does not belong to the associated schedule. + if ($server instanceof Server && $schedule instanceof Schedule) { + $task = $this->route()->parameter('task'); + + if ($schedule->server_id !== $server->id || ($task instanceof Task && $task->schedule_id !== $schedule->id)) { + throw new NotFoundHttpException( + 'The requested resource does not exist on the system.' + ); + } + } + + return true; + } + + /** + * @return string + */ + public function permission(): string + { + return Permission::ACTION_SCHEDULE_READ; + } +} diff --git a/resources/scripts/api/server/schedules/createOrUpdateSchedule.ts b/resources/scripts/api/server/schedules/createOrUpdateSchedule.ts new file mode 100644 index 000000000..0545650be --- /dev/null +++ b/resources/scripts/api/server/schedules/createOrUpdateSchedule.ts @@ -0,0 +1,19 @@ +import { rawDataToServerSchedule, Schedule } from '@/api/server/schedules/getServerSchedules'; +import http from '@/api/http'; + +type Data = Pick & { id?: number } + +export default (uuid: string, schedule: Data): Promise => { + return new Promise((resolve, reject) => { + http.post(`/api/client/servers/${uuid}/schedules${schedule.id ? `/${schedule.id}` : ''}`, { + is_active: schedule.isActive, + name: schedule.name, + minute: schedule.cron.minute, + hour: schedule.cron.hour, + day_of_month: schedule.cron.dayOfMonth, + day_of_week: schedule.cron.dayOfWeek, + }) + .then(({ data }) => resolve(rawDataToServerSchedule(data.attributes))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/server/schedules/deleteSchedule.ts b/resources/scripts/api/server/schedules/deleteSchedule.ts new file mode 100644 index 000000000..c3669988d --- /dev/null +++ b/resources/scripts/api/server/schedules/deleteSchedule.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (uuid: string, schedule: number): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/client/servers/${uuid}/schedules/${schedule}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx b/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx new file mode 100644 index 000000000..179987863 --- /dev/null +++ b/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import Modal from '@/components/elements/Modal'; +import deleteSchedule from '@/api/server/schedules/deleteSchedule'; +import { ServerContext } from '@/state/server'; +import { Actions, useStoreActions } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; +import { httpErrorToHuman } from '@/api/http'; + +interface Props { + scheduleId: number; + onDeleted: () => void; +} + +export default ({ scheduleId, onDeleted }: Props) => { + const [ visible, setVisible ] = useState(false); + const [ isLoading, setIsLoading ] = useState(false); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + + const onDelete = () => { + setIsLoading(true); + clearFlashes('schedules'); + deleteSchedule(uuid, scheduleId) + .then(() => { + setIsLoading(false); + onDeleted(); + }) + .catch(error => { + console.error(error); + + addError({ key: 'schedules', message: httpErrorToHuman(error) }); + setIsLoading(false); + setVisible(false); + }); + }; + + return ( + <> + setVisible(false)} + showSpinnerOverlay={isLoading} + > +

Delete schedule

+

+ 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/EditScheduleModal.tsx b/resources/scripts/components/server/schedules/EditScheduleModal.tsx index c89e36949..003cc1d42 100644 --- a/resources/scripts/components/server/schedules/EditScheduleModal.tsx +++ b/resources/scripts/components/server/schedules/EditScheduleModal.tsx @@ -1,21 +1,20 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Schedule } from '@/api/server/schedules/getServerSchedules'; import Modal, { RequiredModalProps } from '@/components/elements/Modal'; import Field from '@/components/elements/Field'; -import { connect } from 'react-redux'; -import { Form, FormikProps, withFormik } from 'formik'; -import { Actions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; +import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; import Switch from '@/components/elements/Switch'; -import { boolean, object, string } from 'yup'; +import createOrUpdateSchedule from '@/api/server/schedules/createOrUpdateSchedule'; +import { ServerContext } from '@/state/server'; +import { Actions, useStoreActions } from 'easy-peasy'; +import { ApplicationStore } from '@/state'; +import { httpErrorToHuman } from '@/api/http'; +import FlashMessageRender from '@/components/FlashMessageRender'; -type OwnProps = { schedule: Schedule } & RequiredModalProps; - -interface ReduxProps { - addError: ApplicationStore['flashes']['addError']; -} - -type ComponentProps = OwnProps & ReduxProps; +type Props = { + schedule?: Schedule; + onScheduleUpdated: (schedule: Schedule) => void; +} & RequiredModalProps; interface Values { name: string; @@ -26,9 +25,13 @@ interface Values { enabled: boolean; } -const EditScheduleModal = ({ values, schedule, ...props }: ComponentProps & FormikProps) => { +const EditScheduleModal = ({ schedule, ...props }: Omit) => { + const { isSubmitting } = useFormikContext(); + return ( - + +

{schedule ? 'Edit schedule' : 'Create new schedule'}

+
-
@@ -70,29 +73,60 @@ const EditScheduleModal = ({ values, schedule, ...props }: ComponentProps & Form ); }; -export default connect( - null, - // @ts-ignore - (dispatch: Actions) => ({ - addError: dispatch.flashes.addError, - }), -)( - withFormik({ - handleSubmit: (values, { props }) => { - }, +export default ({ schedule, onScheduleUpdated, visible, ...props }: Props) => { + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + const [ modalVisible, setModalVisible ] = useState(visible); - mapPropsToValues: ({ schedule }) => ({ - name: schedule.name, - dayOfWeek: schedule.cron.dayOfWeek, - dayOfMonth: schedule.cron.dayOfMonth, - hour: schedule.cron.hour, - minute: schedule.cron.minute, - enabled: schedule.isActive, - }), + useEffect(() => { + setModalVisible(visible); + clearFlashes('schedule:edit'); + }, [visible]); - validationSchema: object().shape({ - name: string().required(), - enabled: boolean().required(), - }), - })(EditScheduleModal), -); + const submit = (values: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('schedule:edit'); + createOrUpdateSchedule(uuid, { + id: schedule?.id, + name: values.name, + cron: { + minute: values.minute, + hour: values.hour, + dayOfWeek: values.dayOfWeek, + dayOfMonth: values.dayOfMonth, + }, + isActive: values.enabled, + }) + .then(schedule => { + setSubmitting(false); + onScheduleUpdated(schedule); + setModalVisible(false); + }) + .catch(error => { + console.error(error); + + setSubmitting(false); + addError({ key: 'schedule:edit', message: httpErrorToHuman(error) }); + }); + }; + + return ( + + + + ); +}; diff --git a/resources/scripts/components/server/schedules/NewTaskButton.tsx b/resources/scripts/components/server/schedules/NewTaskButton.tsx index f7c776e8b..ac8787b90 100644 --- a/resources/scripts/components/server/schedules/NewTaskButton.tsx +++ b/resources/scripts/components/server/schedules/NewTaskButton.tsx @@ -21,7 +21,7 @@ export default ({ scheduleId, onTaskAdded }: Props) => { }} /> } - diff --git a/resources/scripts/components/server/schedules/ScheduleContainer.tsx b/resources/scripts/components/server/schedules/ScheduleContainer.tsx index 48332554c..63f174a4c 100644 --- a/resources/scripts/components/server/schedules/ScheduleContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleContainer.tsx @@ -2,16 +2,18 @@ import React, { useMemo, useState } from 'react'; import getServerSchedules, { Schedule } from '@/api/server/schedules/getServerSchedules'; import { ServerContext } from '@/state/server'; import Spinner from '@/components/elements/Spinner'; -import { RouteComponentProps, Link } from 'react-router-dom'; +import { RouteComponentProps } from 'react-router-dom'; import FlashMessageRender from '@/components/FlashMessageRender'; import ScheduleRow from '@/components/server/schedules/ScheduleRow'; import { httpErrorToHuman } from '@/api/http'; import { Actions, useStoreActions } from 'easy-peasy'; import { ApplicationStore } from '@/state'; +import EditScheduleModal from '@/components/server/schedules/EditScheduleModal'; export default ({ match, history }: RouteComponentProps) => { const { uuid } = ServerContext.useStoreState(state => state.server.data!); const [ schedules, setSchedules ] = useState(null); + const [ visible, setVisible ] = useState(false); const { clearFlashes, addError } = useStoreActions((actions: Actions) => actions.flashes); useMemo(() => { @@ -30,19 +32,44 @@ export default ({ match, history }: RouteComponentProps) => { {!schedules ? : - schedules.map(schedule => ( - { - e.preventDefault(); - history.push(`${match.url}/${schedule.id}`, { schedule }); - }} - > - - - )) + <> + { + schedules.length === 0 ? +

+ There are no schedules configured for this server. Click the button below to get + started. +

+ : + schedules.map(schedule => ( + { + e.preventDefault(); + history.push(`${match.url}/${schedule.id}`, { schedule }); + }} + > + + + )) + } +
+ {visible && setSchedules(s => [...(s || []), schedule])} + onDismissed={() => setVisible(false)} + />} + +
+ } ); diff --git a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx index 3c2481912..77cfb9ee3 100644 --- a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx @@ -12,6 +12,7 @@ import ScheduleRow from '@/components/server/schedules/ScheduleRow'; import ScheduleTaskRow from '@/components/server/schedules/ScheduleTaskRow'; import EditScheduleModal from '@/components/server/schedules/EditScheduleModal'; import NewTaskButton from '@/components/server/schedules/NewTaskButton'; +import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton'; interface Params { id: string; @@ -21,8 +22,8 @@ interface State { schedule?: Schedule; } -export default ({ match, location: { state } }: RouteComponentProps) => { - const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); +export default ({ match, history, location: { state } }: RouteComponentProps) => { + const { id, uuid } = ServerContext.useStoreState(state => state.server.data!); const [ isLoading, setIsLoading ] = useState(true); const [ showEditModal, setShowEditModal ] = useState(false); const [ schedule, setSchedule ] = useState(state?.schedule); @@ -57,21 +58,13 @@ export default ({ match, location: { state } }: RouteComponentProps setSchedule(schedule)} onDismissed={() => setShowEditModal(false)} /> -
+
-

Schedule Tasks

+

Configured Tasks

- - setSchedule(s => ({ - ...s!, tasks: [ ...s!.tasks, task ], - }))} - />
{schedule?.tasks.length > 0 ? <> @@ -101,9 +94,24 @@ export default ({ match, location: { state } }: RouteComponentProps There are no tasks configured for this schedule. Consider adding a new one using the - button above. + button below.

} +
+ history.push(`/server/${id}/schedules`)} + /> + + setSchedule(s => ({ + ...s!, tasks: [ ...s!.tasks, task ], + }))} + /> +
}
diff --git a/routes/api-client.php b/routes/api-client.php index d23c566be..6b49f58f4 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -61,7 +61,11 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::group(['prefix' => '/schedules'], function () { Route::get('/', 'Servers\ScheduleController@index'); + Route::post('/', 'Servers\ScheduleController@store'); Route::get('/{schedule}', 'Servers\ScheduleController@view'); + Route::post('/{schedule}', 'Servers\ScheduleController@update'); + Route::delete('/{schedule}', 'Servers\ScheduleController@delete'); + Route::post('/{schedule}/tasks', 'Servers\ScheduleTaskController@store'); Route::post('/{schedule}/tasks/{task}', 'Servers\ScheduleTaskController@update'); Route::delete('/{schedule}/tasks/{task}', 'Servers\ScheduleTaskController@delete');