From 807cd815eae575d7b3d6bec1470c8d166057bdc8 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 3 Apr 2020 16:39:55 -0700 Subject: [PATCH 01/13] Fix modal layout positioning --- resources/scripts/components/elements/Modal.tsx | 6 ++++-- .../scripts/components/server/users/EditSubuserModal.tsx | 2 +- resources/styles/components/modal.css | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/resources/scripts/components/elements/Modal.tsx b/resources/scripts/components/elements/Modal.tsx index 88cc78f0c..c37669b02 100644 --- a/resources/scripts/components/elements/Modal.tsx +++ b/resources/scripts/components/elements/Modal.tsx @@ -3,11 +3,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faTimes } from '@fortawesome/free-solid-svg-icons/faTimes'; import { CSSTransition } from 'react-transition-group'; import Spinner from '@/components/elements/Spinner'; +import classNames from 'classnames'; export interface RequiredModalProps { visible: boolean; onDismissed: () => void; appear?: boolean; + top?: boolean; } type Props = RequiredModalProps & { @@ -18,7 +20,7 @@ type Props = RequiredModalProps & { children: React.ReactNode; } -export default ({ visible, appear, dismissable, showSpinnerOverlay, closeOnBackground = true, closeOnEscape = true, onDismissed, children }: Props) => { +export default ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }: Props) => { const [render, setRender] = useState(visible); const isDismissable = useMemo(() => { @@ -58,7 +60,7 @@ export default ({ visible, appear, dismissable, showSpinnerOverlay, closeOnBackg } } }}> -
+
{isDismissable &&
setRender(false)}> diff --git a/resources/scripts/components/server/users/EditSubuserModal.tsx b/resources/scripts/components/server/users/EditSubuserModal.tsx index 5c49edb28..bd6c0dfdb 100644 --- a/resources/scripts/components/server/users/EditSubuserModal.tsx +++ b/resources/scripts/components/server/users/EditSubuserModal.tsx @@ -45,7 +45,7 @@ const EditSubuserModal = forwardRef(({ subuser, ...pr const permissions = useStoreState((state: ApplicationStore) => state.permissions.data); return ( - +

{subuser ? `${canEditUser ? 'Modify' : 'View'} permissions for ${subuser.email}` diff --git a/resources/styles/components/modal.css b/resources/styles/components/modal.css index 5fdceaa87..550191c6b 100644 --- a/resources/styles/components/modal.css +++ b/resources/styles/components/modal.css @@ -6,9 +6,9 @@ & > .modal-container { @apply .relative .w-full .max-w-1/2 .m-auto .flex-col .flex; - /*&.top { + &.top { margin-top: 10%; - }*/ + } & > .modal-close-icon { @apply .absolute .pin-r .p-2 .text-white .cursor-pointer .opacity-50; From 0dbf6b51b5d9b9ee94a605e1dc5fabae696b29de Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 3 Apr 2020 22:39:53 -0700 Subject: [PATCH 02/13] Add very simple search support to pages, togglable with "k" --- resources/scripts/api/getServers.ts | 4 +- .../scripts/components/NavigationBar.tsx | 3 + .../dashboard/search/SearchContainer.tsx | 32 +++++ .../dashboard/search/SearchModal.tsx | 123 ++++++++++++++++++ .../components/elements/InputSpinner.tsx | 22 ++++ resources/scripts/easy-peasy.d.ts | 9 ++ resources/scripts/plugins/useEventListener.ts | 23 ++++ resources/styles/components/navigation.css | 4 +- 8 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 resources/scripts/components/dashboard/search/SearchContainer.tsx create mode 100644 resources/scripts/components/dashboard/search/SearchModal.tsx create mode 100644 resources/scripts/components/elements/InputSpinner.tsx create mode 100644 resources/scripts/easy-peasy.d.ts create mode 100644 resources/scripts/plugins/useEventListener.ts diff --git a/resources/scripts/api/getServers.ts b/resources/scripts/api/getServers.ts index b77440da2..c67322a78 100644 --- a/resources/scripts/api/getServers.ts +++ b/resources/scripts/api/getServers.ts @@ -1,9 +1,9 @@ import { rawDataToServerObject, Server } from '@/api/server/getServer'; import http, { getPaginationSet, PaginatedResult } from '@/api/http'; -export default (): Promise> => { +export default (query?: string): Promise> => { return new Promise((resolve, reject) => { - http.get(`/api/client`, { params: { include: [ 'allocation' ] } }) + http.get(`/api/client`, { params: { include: [ 'allocation' ], query } }) .then(({ data }) => resolve({ items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)), pagination: getPaginationSet(data.meta.pagination), diff --git a/resources/scripts/components/NavigationBar.tsx b/resources/scripts/components/NavigationBar.tsx index 0482069e5..7ff60bef0 100644 --- a/resources/scripts/components/NavigationBar.tsx +++ b/resources/scripts/components/NavigationBar.tsx @@ -8,6 +8,8 @@ import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook'; import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs'; import { useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; +import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch'; +import SearchContainer from '@/components/dashboard/search/SearchContainer'; export default () => { const user = useStoreState((state: ApplicationStore) => state.user.data!); @@ -22,6 +24,7 @@ export default () => {

+ diff --git a/resources/scripts/components/dashboard/search/SearchContainer.tsx b/resources/scripts/components/dashboard/search/SearchContainer.tsx new file mode 100644 index 000000000..475d65510 --- /dev/null +++ b/resources/scripts/components/dashboard/search/SearchContainer.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch'; +import useEventListener from '@/plugins/useEventListener'; +import SearchModal from '@/components/dashboard/search/SearchModal'; + +export default () => { + const [ visible, setVisible ] = useState(false); + + useEventListener('keydown', (e: KeyboardEvent) => { + if ([ 'input', 'textarea' ].indexOf(((e.target as HTMLElement).tagName || 'input').toLowerCase()) < 0) { + if (!visible && e.key.toLowerCase() === 'k') { + setVisible(true); + } + } + }); + + return ( + <> + {visible && + setVisible(false)} + /> + } +
setVisible(true)}> + +
+ + ); +}; diff --git a/resources/scripts/components/dashboard/search/SearchModal.tsx b/resources/scripts/components/dashboard/search/SearchModal.tsx new file mode 100644 index 000000000..af6f6fcca --- /dev/null +++ b/resources/scripts/components/dashboard/search/SearchModal.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Modal, { RequiredModalProps } from '@/components/elements/Modal'; +import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik'; +import { Actions, useStoreActions, useStoreState } from 'easy-peasy'; +import { object, string } from 'yup'; +import { debounce } from 'lodash-es'; +import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; +import InputSpinner from '@/components/elements/InputSpinner'; +import getServers from '@/api/getServers'; +import { Server } from '@/api/server/getServer'; +import { ApplicationStore } from '@/state'; +import { httpErrorToHuman } from '@/api/http'; +import { Link } from 'react-router-dom'; + +type Props = RequiredModalProps; + +interface Values { + term: string; +} + +const SearchWatcher = () => { + const { values, submitForm } = useFormikContext(); + + useEffect(() => { + if (values.term.length >= 3) { + submitForm(); + } + }, [ values.term ]); + + return null; +}; + +export default ({ ...props }: Props) => { + const ref = useRef(null); + const [ loading, setLoading ] = useState(false); + const [ servers, setServers ] = useState([]); + const isAdmin = useStoreState(state => state.user.data!.rootAdmin); + const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + + const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers) => { + setLoading(true); + setSubmitting(false); + clearFlashes('search'); + getServers(term) + .then(servers => setServers(servers.items.filter((_, index) => index < 5))) + .catch(error => { + console.error(error); + addError({ key: 'search', message: httpErrorToHuman(error) }); + }) + .then(() => setLoading(false)); + }, 500); + + useEffect(() => { + if (props.visible) { + setTimeout(() => ref.current?.focus(), 250); + } + }, [ props.visible ]); + + return ( + + +
+ + + + + + +
+ {servers.length > 0 && +
+ { + servers.map(server => ( + props.onDismissed()} + > +
+

{server.name}

+

+ { + server.allocations.filter(alloc => alloc.default).map(allocation => ( + {allocation.alias || allocation.ip}:{allocation.port} + )) + } +

+
+
+ + {server.node} + +
+ + )) + } +
+ } +
+
+ ); +}; diff --git a/resources/scripts/components/elements/InputSpinner.tsx b/resources/scripts/components/elements/InputSpinner.tsx new file mode 100644 index 000000000..1dbc3c84a --- /dev/null +++ b/resources/scripts/components/elements/InputSpinner.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Spinner from '@/components/elements/Spinner'; +import { CSSTransition } from 'react-transition-group'; + +const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => ( +
+ +
+ +
+
+ {children} +
+); + +export default InputSpinner; diff --git a/resources/scripts/easy-peasy.d.ts b/resources/scripts/easy-peasy.d.ts new file mode 100644 index 000000000..939ad54cf --- /dev/null +++ b/resources/scripts/easy-peasy.d.ts @@ -0,0 +1,9 @@ +// noinspection ES6UnusedImports +import EasyPeasy from 'easy-peasy'; +import { ApplicationStore } from '@/state'; + +declare module 'easy-peasy' { + export function useStoreState( + mapState: (state: ApplicationStore) => Result, + ): Result; +} diff --git a/resources/scripts/plugins/useEventListener.ts b/resources/scripts/plugins/useEventListener.ts new file mode 100644 index 000000000..7cb14690a --- /dev/null +++ b/resources/scripts/plugins/useEventListener.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef } from 'react'; + +export default (eventName: string, handler: any, element: any = window) => { + const savedHandler = useRef(null); + + useEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect( + () => { + const isSupported = element && element.addEventListener; + if (!isSupported) return; + + const eventListener = (event: any) => savedHandler.current(event); + element.addEventListener(eventName, eventListener); + return () => { + element.removeEventListener(eventName, eventListener); + }; + }, + [eventName, element], + ); +}; diff --git a/resources/styles/components/navigation.css b/resources/styles/components/navigation.css index 16f64e41d..31951ebfb 100644 --- a/resources/styles/components/navigation.css +++ b/resources/styles/components/navigation.css @@ -21,8 +21,8 @@ & .right-navigation { @apply .flex .h-full .items-center .justify-center; - & > a { - @apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6; + & > a, & > .navigation-link { + @apply .flex .items-center .h-full .no-underline .text-neutral-300 .px-6 .cursor-pointer; transition: background-color 150ms linear, color 150ms linear, box-shadow 150ms ease-in; /*! purgecss start ignore */ From 9d0262e7a1589184d8b9a24c76e2e1c14e91c3cb Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 3 Apr 2020 22:53:49 -0700 Subject: [PATCH 03/13] Update index.blade.php --- resources/views/admin/servers/view/index.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/admin/servers/view/index.blade.php b/resources/views/admin/servers/view/index.blade.php index 10b67c278..eccecba70 100644 --- a/resources/views/admin/servers/view/index.blade.php +++ b/resources/views/admin/servers/view/index.blade.php @@ -79,7 +79,7 @@ @if($server->threads != null) {{ $server->threads }} @else - Not Set + n/a @endif From f51d65229b825fe60ea1ba1dc334f306fe6e76aa Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 3 Apr 2020 23:22:35 -0700 Subject: [PATCH 04/13] Add support for immutable carbon dates in models --- app/Models/Allocation.php | 6 ++--- app/Models/ApiKey.php | 2 +- app/Models/DaemonKey.php | 2 +- app/Models/Database.php | 2 +- app/Models/DatabaseHost.php | 2 +- app/Models/Egg.php | 2 +- app/Models/EggVariable.php | 2 +- app/Models/Location.php | 2 +- app/Models/{Validable.php => Model.php} | 31 +++++++++++++++++++++--- app/Models/Nest.php | 2 +- app/Models/Node.php | 5 ++-- app/Models/Pack.php | 2 +- app/Models/Permission.php | 2 +- app/Models/Schedule.php | 2 +- app/Models/Server.php | 6 +++-- app/Models/Setting.php | 2 +- app/Models/Subuser.php | 2 +- app/Models/Task.php | 2 +- app/Models/Traits/WithImmutableDates.php | 20 +++++++++++++++ app/Models/User.php | 9 +++++-- 20 files changed, 79 insertions(+), 26 deletions(-) rename app/Models/{Validable.php => Model.php} (84%) create mode 100644 app/Models/Traits/WithImmutableDates.php diff --git a/app/Models/Allocation.php b/app/Models/Allocation.php index 00d02de80..5f2435624 100644 --- a/app/Models/Allocation.php +++ b/app/Models/Allocation.php @@ -18,7 +18,7 @@ namespace Pterodactyl\Models; * @property \Pterodactyl\Models\Server|null $server * @property \Pterodactyl\Models\Node $node */ -class Allocation extends Validable +class Allocation extends Model { /** * The resource name for this model when it is transformed into an @@ -75,7 +75,7 @@ class Allocation extends Validable /** * Accessor to automatically provide the IP alias if defined. * - * @param null|string $value + * @param string|null $value * @return string */ public function getAliasAttribute($value) @@ -86,7 +86,7 @@ class Allocation extends Validable /** * Accessor to quickly determine if this allocation has an alias. * - * @param null|string $value + * @param string|null $value * @return bool */ public function getHasAliasAttribute($value) diff --git a/app/Models/ApiKey.php b/app/Models/ApiKey.php index 53a3fa810..59517621a 100644 --- a/app/Models/ApiKey.php +++ b/app/Models/ApiKey.php @@ -16,7 +16,7 @@ use Pterodactyl\Services\Acl\Api\AdminAcl; * @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $updated_at */ -class ApiKey extends Validable +class ApiKey extends Model { const RESOURCE_NAME = 'api_key'; diff --git a/app/Models/DaemonKey.php b/app/Models/DaemonKey.php index 9f5384d54..fa5bb6a91 100644 --- a/app/Models/DaemonKey.php +++ b/app/Models/DaemonKey.php @@ -4,7 +4,7 @@ namespace Pterodactyl\Models; use Znck\Eloquent\Traits\BelongsToThrough; -class DaemonKey extends Validable +class DaemonKey extends Model { use BelongsToThrough; diff --git a/app/Models/Database.php b/app/Models/Database.php index 08ef3e566..2db45734d 100644 --- a/app/Models/Database.php +++ b/app/Models/Database.php @@ -2,7 +2,7 @@ namespace Pterodactyl\Models; -class Database extends Validable +class Database extends Model { /** * The resource name for this model when it is transformed into an diff --git a/app/Models/DatabaseHost.php b/app/Models/DatabaseHost.php index 88d6dcde5..6fafce2f0 100644 --- a/app/Models/DatabaseHost.php +++ b/app/Models/DatabaseHost.php @@ -2,7 +2,7 @@ namespace Pterodactyl\Models; -class DatabaseHost extends Validable +class DatabaseHost extends Model { /** * The resource name for this model when it is transformed into an diff --git a/app/Models/Egg.php b/app/Models/Egg.php index 8db406799..1bdb48ca7 100644 --- a/app/Models/Egg.php +++ b/app/Models/Egg.php @@ -39,7 +39,7 @@ namespace Pterodactyl\Models; * @property \Pterodactyl\Models\Egg|null $scriptFrom * @property \Pterodactyl\Models\Egg|null $configFrom */ -class Egg extends Validable +class Egg extends Model { /** * The resource name for this model when it is transformed into an diff --git a/app/Models/EggVariable.php b/app/Models/EggVariable.php index 727a67b9f..2db891dc9 100644 --- a/app/Models/EggVariable.php +++ b/app/Models/EggVariable.php @@ -2,7 +2,7 @@ namespace Pterodactyl\Models; -class EggVariable extends Validable +class EggVariable extends Model { /** * The resource name for this model when it is transformed into an diff --git a/app/Models/Location.php b/app/Models/Location.php index 20a8e4c38..e0871edf6 100644 --- a/app/Models/Location.php +++ b/app/Models/Location.php @@ -2,7 +2,7 @@ namespace Pterodactyl\Models; -class Location extends Validable +class Location extends Model { /** * The resource name for this model when it is transformed into an diff --git a/app/Models/Validable.php b/app/Models/Model.php similarity index 84% rename from app/Models/Validable.php rename to app/Models/Model.php index f11c8ad05..6607a39f5 100644 --- a/app/Models/Validable.php +++ b/app/Models/Model.php @@ -5,11 +5,21 @@ namespace Pterodactyl\Models; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Container\Container; -use Illuminate\Database\Eloquent\Model; use Illuminate\Contracts\Validation\Factory; +use Pterodactyl\Models\Traits\WithImmutableDates; +use Illuminate\Database\Eloquent\Model as IlluminateModel; -abstract class Validable extends Model +abstract class Model extends IlluminateModel { + use WithImmutableDates; + + /** + * Set to true to return immutable Carbon date instances from the model. + * + * @var bool + */ + protected $immutableDates = false; + /** * Determines if the model should undergo data validation before it is saved * to the database. @@ -47,7 +57,7 @@ abstract class Validable extends Model static::$validatorFactory = Container::getInstance()->make(Factory::class); - static::saving(function (Validable $model) { + static::saving(function (Model $model) { return $model->validate(); }); } @@ -148,4 +158,19 @@ abstract class Validable extends Model ) )->passes(); } + + /** + * Return a timestamp as DateTime object. + * + * @param mixed $value + * @return \Illuminate\Support\Carbon|\Carbon\CarbonImmutable + */ + protected function asDateTime($value) + { + if (! $this->immutableDates) { + return parent::asDateTime($value); + } + + return $this->asImmutableDateTime($value); + } } diff --git a/app/Models/Nest.php b/app/Models/Nest.php index af77dc461..d2839919d 100644 --- a/app/Models/Nest.php +++ b/app/Models/Nest.php @@ -15,7 +15,7 @@ namespace Pterodactyl\Models; * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Egg[] $eggs * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Pack[] $packs */ -class Nest extends Validable +class Nest extends Model { /** * The resource name for this model when it is transformed into an diff --git a/app/Models/Node.php b/app/Models/Node.php index 81cd99383..ca87e78e0 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -32,9 +32,10 @@ use Pterodactyl\Models\Traits\Searchable; * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations */ -class Node extends Validable +class Node extends Model { - use Notifiable, Searchable; + use Notifiable; + use Searchable; /** * The resource name for this model when it is transformed into an diff --git a/app/Models/Pack.php b/app/Models/Pack.php index 092f1cbf2..3846d74eb 100644 --- a/app/Models/Pack.php +++ b/app/Models/Pack.php @@ -20,7 +20,7 @@ use Pterodactyl\Models\Traits\Searchable; * @property \Pterodactyl\Models\Egg|null $egg * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Server[] $servers */ -class Pack extends Validable +class Pack extends Model { use Searchable; diff --git a/app/Models/Permission.php b/app/Models/Permission.php index df19f595c..d91e87da4 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -4,7 +4,7 @@ namespace Pterodactyl\Models; use Illuminate\Support\Collection; -class Permission extends Validable +class Permission extends Model { /** * The resource name for this model when it is transformed into an diff --git a/app/Models/Schedule.php b/app/Models/Schedule.php index 21cad0e59..384d354ad 100644 --- a/app/Models/Schedule.php +++ b/app/Models/Schedule.php @@ -25,7 +25,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface; * @property \Pterodactyl\Models\Server $server * @property \Pterodactyl\Models\Task[]|\Illuminate\Support\Collection $tasks */ -class Schedule extends Validable +class Schedule extends Model { /** * The resource name for this model when it is transformed into an diff --git a/app/Models/Server.php b/app/Models/Server.php index b32703484..2629449a2 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -52,9 +52,11 @@ use Znck\Eloquent\Traits\BelongsToThrough; * @property \Pterodactyl\Models\DaemonKey $key * @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys */ -class Server extends Validable +class Server extends Model { - use BelongsToThrough, Notifiable, Searchable; + use BelongsToThrough; + use Notifiable; + use Searchable; /** * The resource name for this model when it is transformed into an diff --git a/app/Models/Setting.php b/app/Models/Setting.php index c23afb6e8..1a91a578e 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -2,7 +2,7 @@ namespace Pterodactyl\Models; -class Setting extends Validable +class Setting extends Model { /** * The table associated with the model. diff --git a/app/Models/Subuser.php b/app/Models/Subuser.php index e5e8e318e..d75bbe9ab 100644 --- a/app/Models/Subuser.php +++ b/app/Models/Subuser.php @@ -15,7 +15,7 @@ use Illuminate\Notifications\Notifiable; * @property \Pterodactyl\Models\User $user * @property \Pterodactyl\Models\Server $server */ -class Subuser extends Validable +class Subuser extends Model { use Notifiable; diff --git a/app/Models/Task.php b/app/Models/Task.php index 83d4119ff..f5a26b78a 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -22,7 +22,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface; * @property \Pterodactyl\Models\Schedule $schedule * @property \Pterodactyl\Models\Server $server */ -class Task extends Validable +class Task extends Model { use BelongsToThrough; diff --git a/app/Models/Traits/WithImmutableDates.php b/app/Models/Traits/WithImmutableDates.php new file mode 100644 index 000000000..12b2f1669 --- /dev/null +++ b/app/Models/Traits/WithImmutableDates.php @@ -0,0 +1,20 @@ +asDateTime($value)->toImmutable(); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index e3fccad43..c8efc7bd6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -40,12 +40,17 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification; * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys */ -class User extends Validable implements +class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract { - use Authenticatable, Authorizable, AvailableLanguages, CanResetPassword, Notifiable, Searchable; + use Authenticatable; + use Authorizable; + use AvailableLanguages; + use CanResetPassword; + use Notifiable; + use Searchable; const USER_LEVEL_USER = 0; const USER_LEVEL_ADMIN = 1; From 17ec4efd3bacbb87576bb9164f908db6d4e3f98f Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 3 Apr 2020 23:40:20 -0700 Subject: [PATCH 05/13] Add base migration and model for server backups --- app/Models/Backup.php | 59 +++++++++++++++++++ config/backups.php | 29 +++++++++ ...2020_04_03_230614_create_backups_table.php | 39 ++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 app/Models/Backup.php create mode 100644 config/backups.php create mode 100644 database/migrations/2020_04_03_230614_create_backups_table.php diff --git a/app/Models/Backup.php b/app/Models/Backup.php new file mode 100644 index 000000000..663015831 --- /dev/null +++ b/app/Models/Backup.php @@ -0,0 +1,59 @@ + 'int', + 'bytes' => 'int', + ]; + + /** + * @var array + */ + protected $dates = [ + 'completed_at', + ]; + + /** + * Returns dates from this model as immutable Carbon instances. + * + * @param mixed $value + * @return \Carbon\CarbonImmutable + */ + protected function asDateTime($value) + { + return $this->asImmutableDateTime($value); + } +} diff --git a/config/backups.php b/config/backups.php new file mode 100644 index 000000000..57edfee30 --- /dev/null +++ b/config/backups.php @@ -0,0 +1,29 @@ + env('APP_BACKUP_DRIVER', 'local'), + + 'disks' => [ + // There is no configuration for the local disk for Wings. That configuration + // is determined by the Daemon configuration, and not the Panel. + 'local' => [], + + // Configuration for storing backups in Amazon S3. + 's3' => [ + 'region' => '', + 'access_key' => '', + 'access_secret_key' => '', + + // The S3 bucket to use for backups. + 'bucket' => '', + + // The location within the S3 bucket where backups will be stored. Backups + // are stored within a folder using the server's UUID as the name. Each + // backup for that server lives within that folder. + 'location' => '', + ], + ], +]; diff --git a/database/migrations/2020_04_03_230614_create_backups_table.php b/database/migrations/2020_04_03_230614_create_backups_table.php new file mode 100644 index 000000000..963ea9dd7 --- /dev/null +++ b/database/migrations/2020_04_03_230614_create_backups_table.php @@ -0,0 +1,39 @@ +bigIncrements('id'); + $table->char('uuid', 36); + $table->string('name'); + $table->text('contents'); + $table->string('disk'); + $table->string('sha256_hash')->nullable(); + $table->integer('bytes')->default(0); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('backups'); + } +} From 9991989f890cff67e2514f68b933ac6c884b9344 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 4 Apr 2020 10:59:25 -0700 Subject: [PATCH 06/13] Very basic implemention of frontend logic required to display backups and create a new one --- .../Api/Client/Servers/BackupController.php | 54 ++++++++ .../Servers/Backups/GetBackupsRequest.php | 17 +++ .../Servers/Backups/StoreBackupRequest.php | 28 +++++ app/Models/Backup.php | 15 ++- app/Models/Permission.php | 17 +++ app/Models/Server.php | 9 ++ .../Api/Client/BackupTransformer.php | 33 +++++ ...2020_04_03_230614_create_backups_table.php | 6 +- .../api/server/backups/createServerBackup.ts | 12 ++ .../api/server/backups/getServerBackups.ts | 32 +++++ .../server/backups/BackupContainer.tsx | 61 +++++++++ .../server/backups/CreateBackupButton.tsx | 118 ++++++++++++++++++ resources/scripts/plugins/useFlash.ts | 9 ++ resources/scripts/plugins/useServer.ts | 9 ++ resources/scripts/routers/ServerRouter.tsx | 5 + routes/api-client.php | 8 ++ 16 files changed, 431 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/Api/Client/Servers/BackupController.php create mode 100644 app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php create mode 100644 app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php create mode 100644 app/Transformers/Api/Client/BackupTransformer.php create mode 100644 resources/scripts/api/server/backups/createServerBackup.ts create mode 100644 resources/scripts/api/server/backups/getServerBackups.ts create mode 100644 resources/scripts/components/server/backups/BackupContainer.tsx create mode 100644 resources/scripts/components/server/backups/CreateBackupButton.tsx create mode 100644 resources/scripts/plugins/useFlash.ts create mode 100644 resources/scripts/plugins/useServer.ts diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php new file mode 100644 index 000000000..c0d598da4 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -0,0 +1,54 @@ +fractal->collection($server->backups()->paginate(20)) + ->transformWith($this->getTransformer(BackupTransformer::class)) + ->toArray(); + } + + /** + * Starts the backup process for a server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest $request + * @param \Pterodactyl\Models\Server $server + */ + public function store(StoreBackupRequest $request, Server $server) + { + } + + public function view() + { + } + + public function update() + { + } + + public function delete() + { + } +} diff --git a/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php b/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php new file mode 100644 index 000000000..f938906d1 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Backups/GetBackupsRequest.php @@ -0,0 +1,17 @@ + 'nullable|string|max:255', + 'ignore' => 'nullable|string', + ]; + } +} diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 663015831..3384d98ec 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -6,9 +6,10 @@ use Illuminate\Database\Eloquent\SoftDeletes; /** * @property int $id + * @property int $server_id * @property int $uuid * @property string $name - * @property string $contents + * @property string $ignore * @property string $disk * @property string|null $sha256_hash * @property int $bytes @@ -16,11 +17,15 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property \Carbon\CarbonImmutable $created_at * @property \Carbon\CarbonImmutable $updated_at * @property \Carbon\CarbonImmutable|null $deleted_at + * + * @property \Pterodactyl\Models\Server $server */ class Backup extends Model { use SoftDeletes; + const RESOURCE_NAME = 'backup'; + /** * @var string */ @@ -56,4 +61,12 @@ class Backup extends Model { return $this->asImmutableDateTime($value); } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function server() + { + return $this->belongsTo(Server::class); + } } diff --git a/app/Models/Permission.php b/app/Models/Permission.php index d91e87da4..15d70c4ee 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -37,6 +37,12 @@ class Permission extends Model const ACTION_USER_UPDATE = 'user.update'; const ACTION_USER_DELETE = 'user.delete'; + const ACTION_BACKUP_READ = 'backup.read'; + const ACTION_BACKUP_CREATE = 'backup.create'; + const ACTION_BACKUP_UPDATE = 'backup.update'; + const ACTION_BACKUP_DELETE = 'backup.delete'; + const ACTION_BACKUP_DOWNLOAD = 'backup.download'; + const ACTION_ALLOCATION_READ = 'allocation.read'; const ACTION_ALLOCIATION_UPDATE = 'allocation.update'; @@ -135,6 +141,17 @@ class Permission extends Model ], ], + 'backup' => [ + 'description' => 'Permissions that control a user\'s ability to generate and manage server backups.', + 'keys' => [ + 'create' => 'Allows a user to create new backups for this server.', + 'read' => 'Allows a user to view all backups that exist for this server.', + 'update' => '', + 'delete' => 'Allows a user to remove backups from the system.', + 'download' => 'Allows a user to download backups.', + ], + ], + // Controls permissions for editing or viewing a server's allocations. 'allocation' => [ 'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.', diff --git a/app/Models/Server.php b/app/Models/Server.php index 2629449a2..9f5495028 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -51,6 +51,7 @@ use Znck\Eloquent\Traits\BelongsToThrough; * @property \Pterodactyl\Models\Location $location * @property \Pterodactyl\Models\DaemonKey $key * @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys + * @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups */ class Server extends Model { @@ -339,4 +340,12 @@ class Server extends Model { return $this->hasMany(DaemonKey::class); } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function backups() + { + return $this->hasMany(Backup::class); + } } diff --git a/app/Transformers/Api/Client/BackupTransformer.php b/app/Transformers/Api/Client/BackupTransformer.php new file mode 100644 index 000000000..2312b68d3 --- /dev/null +++ b/app/Transformers/Api/Client/BackupTransformer.php @@ -0,0 +1,33 @@ + $backup->uuid, + 'name' => $backup->name, + 'ignore' => $backup->ignore, + 'sha256_hash' => $backup->sha256_hash, + 'bytes' => $backup->bytes, + 'created_at' => $backup->created_at->toIso8601String(), + 'completed_at' => $backup->completed_at->toIso8601String(), + ]; + } +} diff --git a/database/migrations/2020_04_03_230614_create_backups_table.php b/database/migrations/2020_04_03_230614_create_backups_table.php index 963ea9dd7..db1a3ee12 100644 --- a/database/migrations/2020_04_03_230614_create_backups_table.php +++ b/database/migrations/2020_04_03_230614_create_backups_table.php @@ -15,15 +15,19 @@ class CreateBackupsTable extends Migration { Schema::create('backups', function (Blueprint $table) { $table->bigIncrements('id'); + $table->unsignedInteger('server_id'); $table->char('uuid', 36); $table->string('name'); - $table->text('contents'); + $table->text('ignored'); $table->string('disk'); $table->string('sha256_hash')->nullable(); $table->integer('bytes')->default(0); $table->timestamp('completed_at')->nullable(); $table->timestamps(); $table->softDeletes(); + + $table->unique('uuid'); + $table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade'); }); } diff --git a/resources/scripts/api/server/backups/createServerBackup.ts b/resources/scripts/api/server/backups/createServerBackup.ts new file mode 100644 index 000000000..4f0754a54 --- /dev/null +++ b/resources/scripts/api/server/backups/createServerBackup.ts @@ -0,0 +1,12 @@ +import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups'; +import http from '@/api/http'; + +export default (uuid: string, name?: string, ignore?: string): Promise => { + return new Promise((resolve, reject) => { + http.post(`/api/client/servers/${uuid}/backups`, { + name, ignore, + }) + .then(({ data }) => resolve(rawDataToServerBackup(data.attributes))) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/server/backups/getServerBackups.ts b/resources/scripts/api/server/backups/getServerBackups.ts new file mode 100644 index 000000000..6263bcf3a --- /dev/null +++ b/resources/scripts/api/server/backups/getServerBackups.ts @@ -0,0 +1,32 @@ +import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http'; + +export interface ServerBackup { + uuid: string; + name: string; + contents: string; + sha256Hash: string; + bytes: number; + createdAt: Date; + completedAt: Date | null; +} + +export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({ + uuid: attributes.uuid, + name: attributes.name, + contents: attributes.contents, + sha256Hash: attributes.sha256_hash, + bytes: attributes.bytes, + createdAt: new Date(attributes.created_at), + completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null, +}); + +export default (uuid: string, page?: number | string): Promise> => { + return new Promise((resolve, reject) => { + http.get(`/api/client/servers/${uuid}/backups`, { params: { page } }) + .then(({ data }) => resolve({ + items: (data.data || []).map(rawDataToServerBackup), + pagination: getPaginationSet(data.meta.pagination), + })) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx new file mode 100644 index 000000000..3cc0f1757 --- /dev/null +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from 'react'; +import Spinner from '@/components/elements/Spinner'; +import getServerBackups, { ServerBackup } from '@/api/server/backups/getServerBackups'; +import useServer from '@/plugins/useServer'; +import useFlash from '@/plugins/useFlash'; +import { httpErrorToHuman } from '@/api/http'; +import Can from '@/components/elements/Can'; +import CreateBackupButton from '@/components/server/backups/CreateBackupButton'; +import FlashMessageRender from '@/components/FlashMessageRender'; + +export default () => { + const { uuid } = useServer(); + const { addError, clearFlashes } = useFlash(); + const [ loading, setLoading ] = useState(true); + const [ backups, setBackups ] = useState([]); + + useEffect(() => { + clearFlashes('backups'); + getServerBackups(uuid) + .then(data => { + setBackups(data.items); + }) + .catch(error => { + console.error(error); + addError({ key: 'backups', message: httpErrorToHuman(error) }); + }) + .then(() => setLoading(false)); + }, []); + + if (loading) { + return ; + } + + return ( +
+ + {!backups.length ? +

+ There are no backups stored for this server. +

+ : +
+ { + backups.map(backup => ( +
+ {backup.uuid} +
+ )) + } +
+ } + +
+ setBackups(s => [...s, backup])} + /> +
+
+
+ ); +}; diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx new file mode 100644 index 000000000..256a88325 --- /dev/null +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState } from 'react'; +import Modal, { RequiredModalProps } from '@/components/elements/Modal'; +import { Field as FormikField, Form, Formik, FormikHelpers, useFormikContext } from 'formik'; +import { object, string } from 'yup'; +import Field from '@/components/elements/Field'; +import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; +import useFlash from '@/plugins/useFlash'; +import useServer from '@/plugins/useServer'; +import createServerBackup from '@/api/server/backups/createServerBackup'; +import { httpErrorToHuman } from '@/api/http'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import { ServerBackup } from '@/api/server/backups/getServerBackups'; + +interface Values { + name: string; + ignored: string; +} + +interface Props { + onBackupGenerated: (backup: ServerBackup) => void; +} + +const ModalContent = ({ ...props }: RequiredModalProps) => { + const { isSubmitting } = useFormikContext(); + + return ( + +
+ +

Create server backup

+
+ +
+
+ + + +
+
+ +
+ +
+ ); +}; + +export default ({ onBackupGenerated }: Props) => { + const { uuid } = useServer(); + const { addError, clearFlashes } = useFlash(); + const [ visible, setVisible ] = useState(false); + + useEffect(() => { + clearFlashes('backups:create'); + }, [visible]); + + const submit = ({ name, ignored }: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes('backups:create') + createServerBackup(uuid, name, ignored) + .then(backup => { + onBackupGenerated(backup); + setVisible(false); + }) + .catch(error => { + console.error(error); + addError({ key: 'backups:create', message: httpErrorToHuman(error) }); + setSubmitting(false); + }); + }; + + return ( + <> + {visible && + + setVisible(false)} + /> + + } + + + ); +}; diff --git a/resources/scripts/plugins/useFlash.ts b/resources/scripts/plugins/useFlash.ts new file mode 100644 index 000000000..a55b87312 --- /dev/null +++ b/resources/scripts/plugins/useFlash.ts @@ -0,0 +1,9 @@ +import { Actions, useStoreActions } from 'easy-peasy'; +import { FlashStore } from '@/state/flashes'; +import { ApplicationStore } from '@/state'; + +const useFlash = (): Actions => { + return useStoreActions((actions: Actions) => actions.flashes); +}; + +export default useFlash; diff --git a/resources/scripts/plugins/useServer.ts b/resources/scripts/plugins/useServer.ts new file mode 100644 index 000000000..40fd93da1 --- /dev/null +++ b/resources/scripts/plugins/useServer.ts @@ -0,0 +1,9 @@ +import { DependencyList } from 'react'; +import { ServerContext } from '@/state/server'; +import { Server } from '@/api/server/getServer'; + +const useServer = (dependencies?: DependencyList): Server => { + return ServerContext.useStoreState(state => state.server.data!, [ dependencies ]); +}; + +export default useServer; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 241337b79..ce592dfeb 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -16,6 +16,7 @@ import ScheduleContainer from '@/components/server/schedules/ScheduleContainer'; import ScheduleEditContainer from '@/components/server/schedules/ScheduleEditContainer'; import UsersContainer from '@/components/server/users/UsersContainer'; import Can from '@/components/elements/Can'; +import BackupContainer from '@/components/server/backups/BackupContainer'; const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => { const server = ServerContext.useStoreState(state => state.server.data); @@ -47,6 +48,9 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) Users + + Backups + Settings @@ -77,6 +81,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) + diff --git a/routes/api-client.php b/routes/api-client.php index 0a6fa39e9..d0619949f 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -87,6 +87,14 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::delete('/{subuser}', 'Servers\SubuserController@delete'); }); + Route::group(['prefix' => '/backups'], function () { + Route::get('/', 'Servers\BackupController@index'); + Route::post('/', 'Servers\BackupController@store'); + Route::get('/{backup}', 'Servers\BackupController@view'); + Route::post('/{backup}', 'Servers\BackupController@update'); + Route::delete('/{backup}', 'Servers\BackupController@delete'); + }); + Route::group(['prefix' => '/settings'], function () { Route::post('/rename', 'Servers\SettingsController@rename'); Route::post('/reinstall', 'Servers\SettingsController@reinstall'); From 5f42325284a1c7d4fe2329b3826b88652af157fa Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 4 Apr 2020 11:37:10 -0700 Subject: [PATCH 07/13] Don't trigger a recursive nesting call --- app/Models/Model.php | 2 +- app/Models/Traits/WithImmutableDates.php | 20 -------------------- 2 files changed, 1 insertion(+), 21 deletions(-) delete mode 100644 app/Models/Traits/WithImmutableDates.php diff --git a/app/Models/Model.php b/app/Models/Model.php index 6607a39f5..b86a8ab8c 100644 --- a/app/Models/Model.php +++ b/app/Models/Model.php @@ -171,6 +171,6 @@ abstract class Model extends IlluminateModel return parent::asDateTime($value); } - return $this->asImmutableDateTime($value); + return parent::asDateTime($value)->toImmutable(); } } diff --git a/app/Models/Traits/WithImmutableDates.php b/app/Models/Traits/WithImmutableDates.php deleted file mode 100644 index 12b2f1669..000000000 --- a/app/Models/Traits/WithImmutableDates.php +++ /dev/null @@ -1,20 +0,0 @@ -asDateTime($value)->toImmutable(); - } -} From 6d426e45d977f9eff9549327e40b7a601f7dd448 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 4 Apr 2020 11:57:50 -0700 Subject: [PATCH 08/13] Whoops, remove this too --- app/Models/Model.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/Models/Model.php b/app/Models/Model.php index b86a8ab8c..095fe7adc 100644 --- a/app/Models/Model.php +++ b/app/Models/Model.php @@ -6,13 +6,10 @@ use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Container\Container; use Illuminate\Contracts\Validation\Factory; -use Pterodactyl\Models\Traits\WithImmutableDates; use Illuminate\Database\Eloquent\Model as IlluminateModel; abstract class Model extends IlluminateModel { - use WithImmutableDates; - /** * Set to true to return immutable Carbon date instances from the model. * From d27f0c6f2a674876367e699908dfb44bce00dad5 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 4 Apr 2020 12:26:39 -0700 Subject: [PATCH 09/13] Basic backend support to at least store a backup model in the DB --- .../Api/Client/Servers/BackupController.php | 25 ++++++- .../Servers/Backups/StoreBackupRequest.php | 2 +- app/Models/Backup.php | 28 +++++--- .../Eloquent/BackupRepository.php | 16 +++++ .../Backups/InitiateBackupService.php | 69 +++++++++++++++++++ .../Api/Client/BackupTransformer.php | 4 +- ...2020_04_03_230614_create_backups_table.php | 2 +- .../api/server/backups/createServerBackup.ts | 2 +- .../api/server/backups/getServerBackups.ts | 4 +- .../server/backups/CreateBackupButton.tsx | 4 +- 10 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 app/Repositories/Eloquent/BackupRepository.php create mode 100644 app/Services/Backups/InitiateBackupService.php diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php index c0d598da4..64703dd85 100644 --- a/app/Http/Controllers/Api/Client/Servers/BackupController.php +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Pterodactyl\Models\Server; +use Pterodactyl\Services\Backups\InitiateBackupService; use Pterodactyl\Transformers\Api\Client\BackupTransformer; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\GetBackupsRequest; @@ -10,9 +11,21 @@ use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest; class BackupController extends ClientApiController { - public function __construct() + /** + * @var \Pterodactyl\Services\Backups\InitiateBackupService + */ + private $initiateBackupService; + + /** + * BackupController constructor. + * + * @param \Pterodactyl\Services\Backups\InitiateBackupService $initiateBackupService + */ + public function __construct(InitiateBackupService $initiateBackupService) { parent::__construct(); + + $this->initiateBackupService = $initiateBackupService; } /** @@ -35,9 +48,19 @@ class BackupController extends ClientApiController * * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest $request * @param \Pterodactyl\Models\Server $server + * @return array + * + * @throws \Exception */ public function store(StoreBackupRequest $request, Server $server) { + $backup = $this->initiateBackupService + ->setIgnoredFiles($request->input('ignored')) + ->handle($server, $request->input('name')); + + return $this->fractal->item($backup) + ->transformWith($this->getTransformer(BackupTransformer::class)) + ->toArray(); } public function view() diff --git a/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php b/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php index b5ef7110e..145cb4bb1 100644 --- a/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php @@ -21,7 +21,7 @@ class StoreBackupRequest extends ClientApiRequest public function rules(): array { return [ - 'name' => 'nullable|string|max:255', + 'name' => 'nullable|string|max:255|regex:/^[w\][\w\s_.-]*[\w]$/', 'ignore' => 'nullable|string', ]; } diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 3384d98ec..0bb181be9 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property int $server_id * @property int $uuid * @property string $name - * @property string $ignore + * @property string $ignored_files * @property string $disk * @property string|null $sha256_hash * @property int $bytes @@ -52,15 +52,25 @@ class Backup extends Model ]; /** - * Returns dates from this model as immutable Carbon instances. - * - * @param mixed $value - * @return \Carbon\CarbonImmutable + * @var array */ - protected function asDateTime($value) - { - return $this->asImmutableDateTime($value); - } + protected $attributes = [ + 'sha256_hash' => null, + 'bytes' => 0, + ]; + + /** + * @var array + */ + public static $validationRules = [ + 'server_id' => 'bail|required|numeric|exists:servers,id', + 'uuid' => 'required|uuid', + 'name' => 'required|string|regex:/^[w\][\w\s_.-]*[\w]$/', + 'ignored_files' => 'string', + 'disk' => 'required|string', + 'sha256_hash' => 'nullable|string', + 'bytes' => 'numeric', + ]; /** * @return \Illuminate\Database\Eloquent\Relations\BelongsTo diff --git a/app/Repositories/Eloquent/BackupRepository.php b/app/Repositories/Eloquent/BackupRepository.php new file mode 100644 index 000000000..2ff206325 --- /dev/null +++ b/app/Repositories/Eloquent/BackupRepository.php @@ -0,0 +1,16 @@ +repository = $repository; + } + + /** + * Sets the files to be ignored by this backup. + * + * @param string|null $ignored + * @return $this + */ + public function setIgnoredFiles(?string $ignored) + { + $this->ignoredFiles = $ignored; + + return $this; + } + + /** + * Initiates the backup process for a server on the daemon. + * + * @param \Pterodactyl\Models\Server $server + * @param string|null $name + * @return \Pterodactyl\Models\Backup + * + * @throws \Exception + */ + public function handle(Server $server, string $name = null): Backup + { + /** @var \Pterodactyl\Models\Backup $backup */ + $backup = $this->repository->create([ + 'server_id' => $server->id, + 'uuid' => Uuid::uuid4()->toString(), + 'name' => Str::lower(str_replace(' ', '_', trim($name))) ?: sprintf('backup_%s', CarbonImmutable::create()->format('YmdHis')), + 'ignored_files' => $this->ignoredFiles ?? '', + 'disk' => 'local', + ], true, true); + + return $backup; + } +} diff --git a/app/Transformers/Api/Client/BackupTransformer.php b/app/Transformers/Api/Client/BackupTransformer.php index 2312b68d3..53966fc77 100644 --- a/app/Transformers/Api/Client/BackupTransformer.php +++ b/app/Transformers/Api/Client/BackupTransformer.php @@ -23,11 +23,11 @@ class BackupTransformer extends BaseClientTransformer return [ 'uuid' => $backup->uuid, 'name' => $backup->name, - 'ignore' => $backup->ignore, + 'ignored_files' => $backup->ignored_files, 'sha256_hash' => $backup->sha256_hash, 'bytes' => $backup->bytes, 'created_at' => $backup->created_at->toIso8601String(), - 'completed_at' => $backup->completed_at->toIso8601String(), + 'completed_at' => $backup->completed_at ? $backup->completed_at->toIso8601String() : null, ]; } } diff --git a/database/migrations/2020_04_03_230614_create_backups_table.php b/database/migrations/2020_04_03_230614_create_backups_table.php index db1a3ee12..63dad39a0 100644 --- a/database/migrations/2020_04_03_230614_create_backups_table.php +++ b/database/migrations/2020_04_03_230614_create_backups_table.php @@ -18,7 +18,7 @@ class CreateBackupsTable extends Migration $table->unsignedInteger('server_id'); $table->char('uuid', 36); $table->string('name'); - $table->text('ignored'); + $table->text('ignored_files'); $table->string('disk'); $table->string('sha256_hash')->nullable(); $table->integer('bytes')->default(0); diff --git a/resources/scripts/api/server/backups/createServerBackup.ts b/resources/scripts/api/server/backups/createServerBackup.ts index 4f0754a54..2616c2bf3 100644 --- a/resources/scripts/api/server/backups/createServerBackup.ts +++ b/resources/scripts/api/server/backups/createServerBackup.ts @@ -6,7 +6,7 @@ export default (uuid: string, name?: string, ignore?: string): Promise resolve(rawDataToServerBackup(data.attributes))) + .then(({ data }) => resolve(rawDataToServerBackup(data))) .catch(reject); }); }; diff --git a/resources/scripts/api/server/backups/getServerBackups.ts b/resources/scripts/api/server/backups/getServerBackups.ts index 6263bcf3a..49f3aa24c 100644 --- a/resources/scripts/api/server/backups/getServerBackups.ts +++ b/resources/scripts/api/server/backups/getServerBackups.ts @@ -3,7 +3,7 @@ import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/ export interface ServerBackup { uuid: string; name: string; - contents: string; + ignoredFiles: string; sha256Hash: string; bytes: number; createdAt: Date; @@ -13,7 +13,7 @@ export interface ServerBackup { export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({ uuid: attributes.uuid, name: attributes.name, - contents: attributes.contents, + ignoredFiles: attributes.ignored_files, sha256Hash: attributes.sha256_hash, bytes: attributes.bytes, createdAt: new Date(attributes.created_at), diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx index 256a88325..c851bf922 100644 --- a/resources/scripts/components/server/backups/CreateBackupButton.tsx +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -96,7 +96,9 @@ export default ({ onBackupGenerated }: Props) => { onSubmit={submit} initialValues={{ name: '', ignored: '' }} validationSchema={object().shape({ - name: string().max(255), + name: string().required() + .matches(/^[w\][\w\s_.-]*[\w]$/, 'Backup name must only contain alpha-numeric characters, spaces, underscores, dashes, and periods. The name must start and end with an alpha-numeric character.') + .max(255), ignored: string(), })} > From 25b0e86730ed77f23faa81f4aff84d07a29652b6 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 4 Apr 2020 12:30:29 -0700 Subject: [PATCH 10/13] backup names don't actually matter all that much, they don't get used as file names --- .../Api/Client/Servers/Backups/StoreBackupRequest.php | 2 +- app/Models/Backup.php | 2 +- app/Services/Backups/InitiateBackupService.php | 3 +-- .../scripts/components/server/backups/CreateBackupButton.tsx | 4 +--- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php b/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php index 145cb4bb1..b5ef7110e 100644 --- a/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Backups/StoreBackupRequest.php @@ -21,7 +21,7 @@ class StoreBackupRequest extends ClientApiRequest public function rules(): array { return [ - 'name' => 'nullable|string|max:255|regex:/^[w\][\w\s_.-]*[\w]$/', + 'name' => 'nullable|string|max:255', 'ignore' => 'nullable|string', ]; } diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 0bb181be9..56be90f87 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -65,7 +65,7 @@ class Backup extends Model public static $validationRules = [ 'server_id' => 'bail|required|numeric|exists:servers,id', 'uuid' => 'required|uuid', - 'name' => 'required|string|regex:/^[w\][\w\s_.-]*[\w]$/', + 'name' => 'required|string', 'ignored_files' => 'string', 'disk' => 'required|string', 'sha256_hash' => 'nullable|string', diff --git a/app/Services/Backups/InitiateBackupService.php b/app/Services/Backups/InitiateBackupService.php index 18b361ddf..82df733f0 100644 --- a/app/Services/Backups/InitiateBackupService.php +++ b/app/Services/Backups/InitiateBackupService.php @@ -4,7 +4,6 @@ namespace Pterodactyl\Services\Backups; use Ramsey\Uuid\Uuid; use Carbon\CarbonImmutable; -use Illuminate\Support\Str; use Pterodactyl\Models\Backup; use Pterodactyl\Models\Server; use Pterodactyl\Repositories\Eloquent\BackupRepository; @@ -59,7 +58,7 @@ class InitiateBackupService $backup = $this->repository->create([ 'server_id' => $server->id, 'uuid' => Uuid::uuid4()->toString(), - 'name' => Str::lower(str_replace(' ', '_', trim($name))) ?: sprintf('backup_%s', CarbonImmutable::create()->format('YmdHis')), + 'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::create()->toDateTimeString()), 'ignored_files' => $this->ignoredFiles ?? '', 'disk' => 'local', ], true, true); diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx index c851bf922..889e14a07 100644 --- a/resources/scripts/components/server/backups/CreateBackupButton.tsx +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -96,9 +96,7 @@ export default ({ onBackupGenerated }: Props) => { onSubmit={submit} initialValues={{ name: '', ignored: '' }} validationSchema={object().shape({ - name: string().required() - .matches(/^[w\][\w\s_.-]*[\w]$/, 'Backup name must only contain alpha-numeric characters, spaces, underscores, dashes, and periods. The name must start and end with an alpha-numeric character.') - .max(255), + name: string().required().max(255), ignored: string(), })} > From 875358a106d2655b6a77f11dc1662fed95805c9f Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 4 Apr 2020 12:30:45 -0700 Subject: [PATCH 11/13] Not a required field --- .../scripts/components/server/backups/CreateBackupButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx index 889e14a07..256a88325 100644 --- a/resources/scripts/components/server/backups/CreateBackupButton.tsx +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -96,7 +96,7 @@ export default ({ onBackupGenerated }: Props) => { onSubmit={submit} initialValues={{ name: '', ignored: '' }} validationSchema={object().shape({ - name: string().required().max(255), + name: string().max(255), ignored: string(), })} > From 44ff99e83d73558b695b148aa1deb0a00adba75e Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 4 Apr 2020 12:31:21 -0700 Subject: [PATCH 12/13] Use correct time, not an empty time --- app/Services/Backups/InitiateBackupService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Services/Backups/InitiateBackupService.php b/app/Services/Backups/InitiateBackupService.php index 82df733f0..b58fba018 100644 --- a/app/Services/Backups/InitiateBackupService.php +++ b/app/Services/Backups/InitiateBackupService.php @@ -58,7 +58,7 @@ class InitiateBackupService $backup = $this->repository->create([ 'server_id' => $server->id, 'uuid' => Uuid::uuid4()->toString(), - 'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::create()->toDateTimeString()), + 'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()), 'ignored_files' => $this->ignoredFiles ?? '', 'disk' => 'local', ], true, true); From ad9194a65cb82a7a624930b379ba119d616f952e Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Sat, 4 Apr 2020 13:24:03 -0700 Subject: [PATCH 13/13] Build out frontend for viewing server backups --- .../server/backups/BackupContainer.tsx | 13 +++-- .../components/server/backups/BackupRow.tsx | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 resources/scripts/components/server/backups/BackupRow.tsx diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index 3cc0f1757..dee9342c9 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -7,6 +7,7 @@ import { httpErrorToHuman } from '@/api/http'; import Can from '@/components/elements/Can'; import CreateBackupButton from '@/components/server/backups/CreateBackupButton'; import FlashMessageRender from '@/components/FlashMessageRender'; +import BackupRow from '@/components/server/backups/BackupRow'; export default () => { const { uuid } = useServer(); @@ -40,13 +41,11 @@ export default () => {

:
- { - backups.map(backup => ( -
- {backup.uuid} -
- )) - } + {backups.map((backup, index) => )}
} diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx new file mode 100644 index 000000000..ec98cc885 --- /dev/null +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { ServerBackup } from '@/api/server/backups/getServerBackups'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faArchive } from '@fortawesome/free-solid-svg-icons/faArchive'; +import format from 'date-fns/format'; +import distanceInWordsToNow from 'date-fns/distance_in_words_to_now' +import Spinner from '@/components/elements/Spinner'; +import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDownloadAlt'; + +interface Props { + backup: ServerBackup; + className?: string; +} + +export default ({ backup, className }: Props) => { + return ( +
+
+ +
+
+

{backup.name}

+

{backup.uuid}

+
+
+

+ {distanceInWordsToNow(backup.createdAt, { includeSeconds: true, addSuffix: true })} +

+

Created

+
+
+ {!backup.completedAt ? +
+ +
+ : + + + + } +
+
+ ); +};