From 0ebf84275797cae01407d4bbf0694021c7da8148 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 10 Apr 2020 10:46:00 -0700 Subject: [PATCH 1/9] Clean up most of the schedules code to use server context --- .../server/schedules/EditScheduleModal.tsx | 17 ++++---- .../server/schedules/NewTaskButton.tsx | 20 ++++------ .../server/schedules/ScheduleContainer.tsx | 25 +++++++----- .../schedules/ScheduleEditContainer.tsx | 40 ++++++------------- .../server/schedules/ScheduleTaskRow.tsx | 33 ++++++++------- .../server/schedules/TaskDetailsModal.tsx | 37 +++++++++++------ .../server/users/UsersContainer.tsx | 15 ++++--- resources/scripts/state/server/index.ts | 6 ++- resources/scripts/state/server/schedules.ts | 31 ++++++++++++++ 9 files changed, 132 insertions(+), 92 deletions(-) create mode 100644 resources/scripts/state/server/schedules.ts diff --git a/resources/scripts/components/server/schedules/EditScheduleModal.tsx b/resources/scripts/components/server/schedules/EditScheduleModal.tsx index 003cc1d42..648dd6f51 100644 --- a/resources/scripts/components/server/schedules/EditScheduleModal.tsx +++ b/resources/scripts/components/server/schedules/EditScheduleModal.tsx @@ -6,14 +6,13 @@ import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; import Switch from '@/components/elements/Switch'; 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'; +import useServer from '@/plugins/useServer'; +import useFlash from '@/plugins/useFlash'; type Props = { schedule?: Schedule; - onScheduleUpdated: (schedule: Schedule) => void; } & RequiredModalProps; interface Values { @@ -73,15 +72,17 @@ const EditScheduleModal = ({ schedule, ...props }: Omit { - const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); - const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); +export default ({ schedule, visible, ...props }: Props) => { + const { uuid } = useServer(); + const { addError, clearFlashes } = useFlash(); const [ modalVisible, setModalVisible ] = useState(visible); + const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule); + useEffect(() => { setModalVisible(visible); clearFlashes('schedule:edit'); - }, [visible]); + }, [ visible ]); const submit = (values: Values, { setSubmitting }: FormikHelpers) => { clearFlashes('schedule:edit'); @@ -98,7 +99,7 @@ export default ({ schedule, onScheduleUpdated, visible, ...props }: Props) => { }) .then(schedule => { setSubmitting(false); - onScheduleUpdated(schedule); + appendSchedule(schedule); setModalVisible(false); }) .catch(error => { diff --git a/resources/scripts/components/server/schedules/NewTaskButton.tsx b/resources/scripts/components/server/schedules/NewTaskButton.tsx index ac8787b90..c53f1b17c 100644 --- a/resources/scripts/components/server/schedules/NewTaskButton.tsx +++ b/resources/scripts/components/server/schedules/NewTaskButton.tsx @@ -1,25 +1,21 @@ import React, { useState } from 'react'; -import { Task } from '@/api/server/schedules/getServerSchedules'; +import { Schedule } from '@/api/server/schedules/getServerSchedules'; import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal'; interface Props { - scheduleId: number; - onTaskAdded: (task: Task) => void; + schedule: Schedule; } -export default ({ scheduleId, onTaskAdded }: Props) => { - const [visible, setVisible] = useState(false); +export default ({ schedule }: Props) => { + const [ visible, setVisible ] = useState(false); return ( <> {visible && - { - task && onTaskAdded(task); - setVisible(false); - }} - /> + setVisible(false)} + /> } - setSchedule(s => ({ - ...s!, tasks: [ ...s!.tasks, task ], - }))} - /> + diff --git a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx index 93bd4d326..516ffa9ae 100644 --- a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx +++ b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx @@ -1,39 +1,41 @@ import React, { useState } from 'react'; -import { Task } from '@/api/server/schedules/getServerSchedules'; +import { Schedule, Task } from '@/api/server/schedules/getServerSchedules'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'; import { faCode } from '@fortawesome/free-solid-svg-icons/faCode'; import { faToggleOn } from '@fortawesome/free-solid-svg-icons/faToggleOn'; import ConfirmTaskDeletionModal from '@/components/server/schedules/ConfirmTaskDeletionModal'; -import { ServerContext } from '@/state/server'; -import { Actions, useStoreActions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; import deleteScheduleTask from '@/api/server/schedules/deleteScheduleTask'; import { httpErrorToHuman } from '@/api/http'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal'; import { faPencilAlt } from '@fortawesome/free-solid-svg-icons/faPencilAlt'; import Can from '@/components/elements/Can'; +import useServer from '@/plugins/useServer'; +import useFlash from '@/plugins/useFlash'; +import { ServerContext } from '@/state/server'; interface Props { - schedule: number; + schedule: Schedule; task: Task; - onTaskUpdated: (task: Task) => void; - onTaskRemoved: () => void; } -export default ({ schedule, task, onTaskUpdated, onTaskRemoved }: Props) => { +export default ({ schedule, task }: Props) => { + const { uuid } = useServer(); + const { clearFlashes, addError } = useFlash(); const [ visible, setVisible ] = useState(false); const [ isLoading, setIsLoading ] = useState(false); const [ isEditing, setIsEditing ] = useState(false); - const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); - const { clearFlashes, addError } = useStoreActions((actions: Actions) => actions.flashes); + const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule); const onConfirmDeletion = () => { setIsLoading(true); clearFlashes('schedules'); - deleteScheduleTask(uuid, schedule, task.id) - .then(() => onTaskRemoved()) + deleteScheduleTask(uuid, schedule.id, task.id) + .then(() => appendSchedule({ + ...schedule, + tasks: schedule.tasks.filter(t => t.id !== task.id), + })) .catch(error => { console.error(error); setIsLoading(false); @@ -45,12 +47,9 @@ export default ({ schedule, task, onTaskUpdated, onTaskRemoved }: Props) => {
{isEditing && { - task && onTaskUpdated(task); - setIsEditing(false); - }} + onDismissed={() => setIsEditing(false)} />} void; + onDismissed: () => void; } interface Values { @@ -29,9 +29,11 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => { const { values: { action }, setFieldValue, setFieldTouched } = useFormikContext(); useEffect(() => { - setFieldValue('payload', ''); - setFieldTouched('payload', false); - }, [action]); + return () => { + setFieldValue('payload', ''); + setFieldTouched('payload', false); + }; + }, [ action ]); return (
@@ -80,9 +82,10 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => { ); }; -export default ({ task, scheduleId, onDismissed }: Props) => { - const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); - const { clearFlashes, addError } = useStoreActions((actions: Actions) => actions.flashes); +export default ({ task, schedule, onDismissed }: Props) => { + const { uuid } = useServer(); + const { clearFlashes, addError } = useFlash(); + const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule); useEffect(() => { clearFlashes('schedule:task'); @@ -90,8 +93,16 @@ export default ({ task, scheduleId, onDismissed }: Props) => { const submit = (values: Values, { setSubmitting }: FormikHelpers) => { clearFlashes('schedule:task'); - createOrUpdateScheduleTask(uuid, scheduleId, task?.id, values) - .then(task => onDismissed(task)) + createOrUpdateScheduleTask(uuid, schedule.id, task?.id, values) + .then(task => { + let tasks = schedule.tasks.map(t => t.id === task.id ? task : t); + if (!schedule.tasks.find(t => t.id === task.id)) { + tasks = [ ...tasks, task ]; + } + + appendSchedule({ ...schedule, tasks }); + onDismissed(); + }) .catch(error => { console.error(error); setSubmitting(false); diff --git a/resources/scripts/components/server/users/UsersContainer.tsx b/resources/scripts/components/server/users/UsersContainer.tsx index a6c863ea0..699834d6c 100644 --- a/resources/scripts/components/server/users/UsersContainer.tsx +++ b/resources/scripts/components/server/users/UsersContainer.tsx @@ -9,6 +9,7 @@ import FlashMessageRender from '@/components/FlashMessageRender'; import getServerSubusers from '@/api/server/users/getServerSubusers'; import { httpErrorToHuman } from '@/api/http'; import Can from '@/components/elements/Can'; +import ListRefreshIndicator from '@/components/elements/ListRefreshIndicator'; export default () => { const [ loading, setLoading ] = useState(true); @@ -21,10 +22,6 @@ export default () => { const getPermissions = useStoreActions((actions: Actions) => actions.permissions.getPermissions); const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); - useEffect(() => { - getPermissions().catch(error => console.error(error)); - }, []); - useEffect(() => { clearFlashes('users'); getServerSubusers(uuid) @@ -38,12 +35,20 @@ export default () => { }); }, []); - if (loading || !Object.keys(permissions).length) { + useEffect(() => { + getPermissions().catch(error => { + addError({ key: 'users', message: httpErrorToHuman(error) }); + console.error(error); + }); + }, []); + + if (!subusers.length && (loading || !Object.keys(permissions).length)) { return ; } return (
+ {!subusers.length ?

diff --git a/resources/scripts/state/server/index.ts b/resources/scripts/state/server/index.ts index 45acb7eae..32b3a7c54 100644 --- a/resources/scripts/state/server/index.ts +++ b/resources/scripts/state/server/index.ts @@ -6,6 +6,7 @@ import files, { ServerFileStore } from '@/state/server/files'; import subusers, { ServerSubuserStore } from '@/state/server/subusers'; import { composeWithDevTools } from 'redux-devtools-extension'; import backups, { ServerBackupStore } from '@/state/server/backups'; +import schedules, { ServerScheduleStore } from '@/state/server/schedules'; export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running'; @@ -74,6 +75,7 @@ export interface ServerStore { subusers: ServerSubuserStore; databases: ServerDatabaseStore; files: ServerFileStore; + schedules: ServerScheduleStore; backups: ServerBackupStore; socket: SocketStore; status: ServerStatusStore; @@ -88,6 +90,7 @@ export const ServerContext = createContextStore({ files, subusers, backups, + schedules, clearServerState: action(state => { state.server.data = undefined; state.server.permissions = []; @@ -95,7 +98,8 @@ export const ServerContext = createContextStore({ state.subusers.data = []; state.files.directory = '/'; state.files.contents = []; - state.backups.backups = []; + state.backups.data = []; + state.schedules.data = []; if (state.socket.instance) { state.socket.instance.removeAllListeners(); diff --git a/resources/scripts/state/server/schedules.ts b/resources/scripts/state/server/schedules.ts new file mode 100644 index 000000000..47504ef7a --- /dev/null +++ b/resources/scripts/state/server/schedules.ts @@ -0,0 +1,31 @@ +import { action, Action } from 'easy-peasy'; +import { Schedule } from '@/api/server/schedules/getServerSchedules'; + +export interface ServerScheduleStore { + data: Schedule[]; + setSchedules: Action; + appendSchedule: Action; + removeSchedule: Action; +} + +const schedules: ServerScheduleStore = { + data: [], + + setSchedules: action((state, payload) => { + state.data = payload; + }), + + appendSchedule: action((state, payload) => { + if (state.data.find(schedule => schedule.id === payload.id)) { + state.data = state.data.map(schedule => schedule.id === payload.id ? payload : schedule); + } else { + state.data = [ ...state.data, payload ]; + } + }), + + removeSchedule: action((state, payload) => { + state.data = [ ...state.data.filter(schedule => schedule.id !== payload) ]; + }), +}; + +export default schedules; From 708c15eba8779e7897273f976e5387e7128bdea5 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 10 Apr 2020 10:56:25 -0700 Subject: [PATCH 2/9] Make database rows use context better --- .../server/databases/CreateDatabaseButton.tsx | 34 +++++++------- .../server/databases/DatabaseRow.tsx | 34 ++++++-------- .../server/databases/DatabasesContainer.tsx | 44 +++++++++---------- resources/scripts/state/server/databases.ts | 31 +++++++++++++ resources/scripts/state/server/index.ts | 26 ++--------- 5 files changed, 83 insertions(+), 86 deletions(-) create mode 100644 resources/scripts/state/server/databases.ts diff --git a/resources/scripts/components/server/databases/CreateDatabaseButton.tsx b/resources/scripts/components/server/databases/CreateDatabaseButton.tsx index 25b488e08..cf5b8c63d 100644 --- a/resources/scripts/components/server/databases/CreateDatabaseButton.tsx +++ b/resources/scripts/components/server/databases/CreateDatabaseButton.tsx @@ -1,15 +1,14 @@ import React, { useState } from 'react'; -import { ServerDatabase } from '@/api/server/getServerDatabases'; import Modal from '@/components/elements/Modal'; import { Form, Formik, FormikHelpers } from 'formik'; import Field from '@/components/elements/Field'; import { object, string } from 'yup'; import createServerDatabase from '@/api/server/createServerDatabase'; 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'; +import useFlash from '@/plugins/useFlash'; +import useServer from '@/plugins/useServer'; interface Values { databaseName: string; @@ -27,28 +26,25 @@ const schema = object().shape({ .matches(/^([1-9]{1,3}|%)(\.([0-9]{1,3}|%))?(\.([0-9]{1,3}|%))?(\.([0-9]{1,3}|%))?$/, 'A valid connection address must be provided.'), }); -export default ({ onCreated }: { onCreated: (database: ServerDatabase) => void }) => { +export default () => { + const { uuid } = useServer(); + const { addError, clearFlashes } = useFlash(); const [ visible, setVisible ] = useState(false); - const { addFlash, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); - const server = ServerContext.useStoreState(state => state.server.data!); + + const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase); const submit = (values: Values, { setSubmitting }: FormikHelpers) => { - clearFlashes(); - createServerDatabase(server.uuid, { ...values }) + clearFlashes('database:create'); + createServerDatabase(uuid, { ...values }) .then(database => { - onCreated(database); + appendDatabase(database); setVisible(false); }) .catch(error => { console.log(error); - addFlash({ - key: 'create-database-modal', - type: 'error', - title: 'Error', - message: httpErrorToHuman(error), - }); - }) - .then(() => setSubmitting(false)); + addError({ key: 'database:create', message: httpErrorToHuman(error) }); + setSubmitting(false); + }); }; return ( @@ -69,7 +65,7 @@ export default ({ onCreated }: { onCreated: (database: ServerDatabase) => void } setVisible(false); }} > - +

Create new database

void } ) } - diff --git a/resources/scripts/components/server/databases/DatabaseRow.tsx b/resources/scripts/components/server/databases/DatabaseRow.tsx index 1fd07774d..a80b304c3 100644 --- a/resources/scripts/components/server/databases/DatabaseRow.tsx +++ b/resources/scripts/components/server/databases/DatabaseRow.tsx @@ -9,31 +9,28 @@ import { Form, Formik, FormikHelpers } from 'formik'; import Field from '@/components/elements/Field'; import { object, string } from 'yup'; import FlashMessageRender from '@/components/FlashMessageRender'; -import { Actions, useStoreActions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; import { ServerContext } from '@/state/server'; import deleteServerDatabase from '@/api/server/deleteServerDatabase'; import { httpErrorToHuman } from '@/api/http'; import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton'; import Can from '@/components/elements/Can'; +import { ServerDatabase } from '@/api/server/getServerDatabases'; +import useServer from '@/plugins/useServer'; +import useFlash from '@/plugins/useFlash'; interface Props { - databaseId: string | number; + database: ServerDatabase; className?: string; - onDelete: () => void; } -export default ({ databaseId, className, onDelete }: Props) => { +export default ({ database, className }: Props) => { + const { uuid } = useServer(); + const { addError, clearFlashes } = useFlash(); const [ visible, setVisible ] = useState(false); - const database = ServerContext.useStoreState(state => state.databases.items.find(item => item.id === databaseId)); - const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase); const [ connectionVisible, setConnectionVisible ] = useState(false); - const { addFlash, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); - const server = ServerContext.useStoreState(state => state.server.data!); - if (!database) { - return null; - } + const appendDatabase = ServerContext.useStoreActions(actions => actions.databases.appendDatabase); + const removeDatabase = ServerContext.useStoreActions(actions => actions.databases.removeDatabase); const schema = object().shape({ confirm: string() @@ -43,20 +40,15 @@ export default ({ databaseId, className, onDelete }: Props) => { const submit = (values: { confirm: string }, { setSubmitting }: FormikHelpers<{ confirm: string }>) => { clearFlashes(); - deleteServerDatabase(server.uuid, database.id) + deleteServerDatabase(uuid, database.id) .then(() => { setVisible(false); - setTimeout(() => onDelete(), 150); + setTimeout(() => removeDatabase(database.id), 150); }) .catch(error => { console.error(error); setSubmitting(false); - addFlash({ - key: 'delete-database-modal', - type: 'error', - title: 'Error', - message: httpErrorToHuman(error), - }); + addError({ key: 'database:delete', message: httpErrorToHuman(error) }); }); }; @@ -78,7 +70,7 @@ export default ({ databaseId, className, onDelete }: Props) => { resetForm(); }} > - +

Confirm database deletion

Deleting a database is a permanent action, it cannot be undone. This will permanetly diff --git a/resources/scripts/components/server/databases/DatabasesContainer.tsx b/resources/scripts/components/server/databases/DatabasesContainer.tsx index a5335f5c0..0a90f985a 100644 --- a/resources/scripts/components/server/databases/DatabasesContainer.tsx +++ b/resources/scripts/components/server/databases/DatabasesContainer.tsx @@ -1,8 +1,6 @@ import React, { useEffect, useState } from 'react'; import getServerDatabases from '@/api/server/getServerDatabases'; 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'; import DatabaseRow from '@/components/server/databases/DatabaseRow'; @@ -10,51 +8,51 @@ import Spinner from '@/components/elements/Spinner'; import { CSSTransition } from 'react-transition-group'; import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton'; import Can from '@/components/elements/Can'; +import useFlash from '@/plugins/useFlash'; +import useServer from '@/plugins/useServer'; +import ListRefreshIndicator from '@/components/elements/ListRefreshIndicator'; export default () => { + const { uuid, featureLimits } = useServer(); + const { addError, clearFlashes } = useFlash(); const [ loading, setLoading ] = useState(true); - const server = ServerContext.useStoreState(state => state.server.data!); - const databases = ServerContext.useStoreState(state => state.databases.items); - const { setDatabases, appendDatabase, removeDatabase } = ServerContext.useStoreActions(state => state.databases); - const { addFlash, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + + const databases = ServerContext.useStoreState(state => state.databases.data); + const setDatabases = ServerContext.useStoreActions(state => state.databases.setDatabases); useEffect(() => { setLoading(!databases.length); clearFlashes('databases'); - getServerDatabases(server.uuid) - .then(databases => { - setDatabases(databases); - setLoading(false); + getServerDatabases(uuid) + .then(databases => setDatabases(databases)) + .catch(error => { + console.error(error); + addError({ key: 'databases', message: httpErrorToHuman(error) }); }) - .catch(error => addFlash({ - key: 'databases', - title: 'Error', - message: httpErrorToHuman(error), - type: 'error', - })); + .then(() => setLoading(false)); }, []); return (

- - {loading ? + + {(!databases.length && loading) ? : <> + {databases.length > 0 ? databases.map((database, index) => ( removeDatabase(database)} + database={database} className={index > 0 ? 'mt-1' : undefined} /> )) :

- {server.featureLimits.databases > 0 ? + {featureLimits.databases > 0 ? `It looks like you have no databases.` : `Databases cannot be created for this server.` @@ -62,9 +60,9 @@ export default () => {

} - {server.featureLimits.databases > 0 && + {featureLimits.databases > 0 &&
- +
}
diff --git a/resources/scripts/state/server/databases.ts b/resources/scripts/state/server/databases.ts new file mode 100644 index 000000000..7fa697dbd --- /dev/null +++ b/resources/scripts/state/server/databases.ts @@ -0,0 +1,31 @@ +import { action, Action } from 'easy-peasy'; +import { ServerDatabase } from '@/api/server/getServerDatabases'; + +export interface ServerDatabaseStore { + data: ServerDatabase[]; + setDatabases: Action; + appendDatabase: Action; + removeDatabase: Action; +} + +const databases: ServerDatabaseStore = { + data: [], + + setDatabases: action((state, payload) => { + state.data = payload; + }), + + appendDatabase: action((state, payload) => { + if (state.data.find(database => database.id === payload.id)) { + state.data = state.data.map(database => database.id === payload.id ? payload : database); + } else { + state.data = [ ...state.data, payload ]; + } + }), + + removeDatabase: action((state, payload) => { + state.data = [ ...state.data.filter(database => database.id !== payload) ]; + }), +}; + +export default databases; diff --git a/resources/scripts/state/server/index.ts b/resources/scripts/state/server/index.ts index 32b3a7c54..e6d389668 100644 --- a/resources/scripts/state/server/index.ts +++ b/resources/scripts/state/server/index.ts @@ -1,12 +1,12 @@ import getServer, { Server } from '@/api/server/getServer'; import { action, Action, createContextStore, thunk, Thunk } from 'easy-peasy'; import socket, { SocketStore } from './socket'; -import { ServerDatabase } from '@/api/server/getServerDatabases'; import files, { ServerFileStore } from '@/state/server/files'; import subusers, { ServerSubuserStore } from '@/state/server/subusers'; import { composeWithDevTools } from 'redux-devtools-extension'; import backups, { ServerBackupStore } from '@/state/server/backups'; import schedules, { ServerScheduleStore } from '@/state/server/schedules'; +import databases, { ServerDatabaseStore } from '@/state/server/databases'; export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running'; @@ -23,7 +23,7 @@ const server: ServerDataStore = { permissions: [], getServer: thunk(async (actions, payload) => { - const [server, permissions] = await getServer(payload); + const [ server, permissions ] = await getServer(payload); actions.setServer(server); actions.setPermissions(permissions); @@ -50,26 +50,6 @@ const status: ServerStatusStore = { }), }; -interface ServerDatabaseStore { - items: ServerDatabase[]; - setDatabases: Action; - appendDatabase: Action; - removeDatabase: Action; -} - -const databases: ServerDatabaseStore = { - items: [], - setDatabases: action((state, payload) => { - state.items = payload; - }), - appendDatabase: action((state, payload) => { - state.items = state.items.filter(item => item.id !== payload.id).concat(payload); - }), - removeDatabase: action((state, payload) => { - state.items = state.items.filter(item => item.id !== payload.id); - }), -}; - export interface ServerStore { server: ServerDataStore; subusers: ServerSubuserStore; @@ -94,7 +74,7 @@ export const ServerContext = createContextStore({ clearServerState: action(state => { state.server.data = undefined; state.server.permissions = []; - state.databases.items = []; + state.databases.data = []; state.subusers.data = []; state.files.directory = '/'; state.files.contents = []; From d3a06e1ca8ee3e0d46babaaf1500fc3a93161de5 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 10 Apr 2020 12:41:08 -0700 Subject: [PATCH 3/9] Add progress bar to top of page for nicer loading indicator styles --- resources/scripts/api/http.ts | 14 ++++ resources/scripts/components/App.tsx | 2 + .../elements/ListRefreshIndicator.tsx | 19 ----- .../components/elements/ProgressBar.tsx | 73 +++++++++++++++++++ .../server/backups/BackupContainer.tsx | 4 +- .../server/databases/DatabasesContainer.tsx | 2 - .../server/schedules/ScheduleContainer.tsx | 8 +- .../server/users/UsersContainer.tsx | 2 - resources/scripts/easy-peasy.d.ts | 8 +- resources/scripts/helpers.ts | 2 + resources/scripts/state/index.ts | 3 + resources/scripts/state/progress.ts | 30 ++++++++ 12 files changed, 133 insertions(+), 34 deletions(-) delete mode 100644 resources/scripts/components/elements/ListRefreshIndicator.tsx create mode 100644 resources/scripts/components/elements/ProgressBar.tsx create mode 100644 resources/scripts/state/progress.ts diff --git a/resources/scripts/api/http.ts b/resources/scripts/api/http.ts index 14b27cdbc..d839887b0 100644 --- a/resources/scripts/api/http.ts +++ b/resources/scripts/api/http.ts @@ -1,6 +1,8 @@ import axios, { AxiosInstance } from 'axios'; +import { store } from '@/state'; const http: AxiosInstance = axios.create({ + timeout: 20000, headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', @@ -9,6 +11,18 @@ const http: AxiosInstance = axios.create({ }, }); +http.interceptors.request.use(req => { + store.getActions().progress.startContinuous(); + + return req; +}); + +http.interceptors.response.use(resp => { + store.getActions().progress.setComplete(); + + return resp; +}); + // If we have a phpdebugbar instance registered at this point in time go // ahead and route the response data through to it so things show up. // @ts-ignore diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index fb813b361..e8aa002d3 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -9,6 +9,7 @@ import AuthenticationRouter from '@/routers/AuthenticationRouter'; import { Provider } from 'react-redux'; import { SiteSettings } from '@/state/settings'; import { DefaultTheme, ThemeProvider } from 'styled-components'; +import ProgressBar from '@/components/elements/ProgressBar'; interface ExtendedWindow extends Window { SiteConfiguration?: SiteSettings; @@ -57,6 +58,7 @@ const App = () => { +
diff --git a/resources/scripts/components/elements/ListRefreshIndicator.tsx b/resources/scripts/components/elements/ListRefreshIndicator.tsx deleted file mode 100644 index 075f53631..000000000 --- a/resources/scripts/components/elements/ListRefreshIndicator.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import Spinner from '@/components/elements/Spinner'; -import { CSSTransition } from 'react-transition-group'; - -interface Props { - visible: boolean; - children?: React.ReactChild; -} - -const ListRefreshIndicator = ({ visible, children }: Props) => ( - -
- -

{children || 'Refreshing listing...'}

-
-
-); - -export default ListRefreshIndicator; diff --git a/resources/scripts/components/elements/ProgressBar.tsx b/resources/scripts/components/elements/ProgressBar.tsx new file mode 100644 index 000000000..c4b867928 --- /dev/null +++ b/resources/scripts/components/elements/ProgressBar.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; +import { useStoreActions, useStoreState } from 'easy-peasy'; +import { randomInt } from '@/helpers'; +import { CSSTransition } from 'react-transition-group'; + +const BarFill = styled.div` + ${tw`h-full bg-cyan-400`}; + transition: 250ms ease-in-out; + box-shadow: 0 -2px 10px 2px hsl(178, 78%, 57%); +`; + +export default () => { + const interval = useRef(null); + const timeout = useRef(null); + const [ visible, setVisible ] = useState(false); + const progress = useStoreState(state => state.progress.progress); + const continuous = useStoreState(state => state.progress.continuous); + const setProgress = useStoreActions(actions => actions.progress.setProgress); + + useEffect(() => { + return () => { + timeout.current && clearTimeout(timeout.current); + interval.current && clearInterval(interval.current); + }; + }, []); + + useEffect(() => { + setVisible((progress || 0) > 0); + + if (progress === 100) { + // @ts-ignore + timeout.current = setTimeout(() => setProgress(undefined), 500); + } + }, [ progress ]); + + useEffect(() => { + if (!continuous) { + interval.current && clearInterval(interval.current); + return; + } + + if (!progress || progress === 0) { + setProgress(randomInt(20, 30)); + } + }, [ continuous ]); + + useEffect(() => { + if (continuous) { + interval.current && clearInterval(interval.current); + if ((progress || 0) >= 90) { + setProgress(90); + } else { + // @ts-ignore + interval.current = setTimeout(() => setProgress(progress + randomInt(1, 5)), 500); + } + } + }, [ progress, continuous ]); + + return ( +
+ + + +
+ ); +}; diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index f38410768..e71b3c2f9 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import Spinner from '@/components/elements/Spinner'; -import getServerBackups, { ServerBackup } from '@/api/server/backups/getServerBackups'; +import getServerBackups from '@/api/server/backups/getServerBackups'; import useServer from '@/plugins/useServer'; import useFlash from '@/plugins/useFlash'; import { httpErrorToHuman } from '@/api/http'; @@ -9,7 +9,6 @@ import CreateBackupButton from '@/components/server/backups/CreateBackupButton'; import FlashMessageRender from '@/components/FlashMessageRender'; import BackupRow from '@/components/server/backups/BackupRow'; import { ServerContext } from '@/state/server'; -import ListRefreshIndicator from '@/components/elements/ListRefreshIndicator'; export default () => { const { uuid } = useServer(); @@ -36,7 +35,6 @@ export default () => { return (
- {!backups.length ?

diff --git a/resources/scripts/components/server/databases/DatabasesContainer.tsx b/resources/scripts/components/server/databases/DatabasesContainer.tsx index 0a90f985a..ce2189aca 100644 --- a/resources/scripts/components/server/databases/DatabasesContainer.tsx +++ b/resources/scripts/components/server/databases/DatabasesContainer.tsx @@ -10,7 +10,6 @@ import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseBu import Can from '@/components/elements/Can'; import useFlash from '@/plugins/useFlash'; import useServer from '@/plugins/useServer'; -import ListRefreshIndicator from '@/components/elements/ListRefreshIndicator'; export default () => { const { uuid, featureLimits } = useServer(); @@ -41,7 +40,6 @@ export default () => { : <> - {databases.length > 0 ? databases.map((database, index) => ( { const { uuid } = useServer(); @@ -37,7 +34,6 @@ export default ({ match, history }: RouteComponentProps) => { return (

- {(!schedules.length && loading) ? : diff --git a/resources/scripts/components/server/users/UsersContainer.tsx b/resources/scripts/components/server/users/UsersContainer.tsx index 699834d6c..8d511a272 100644 --- a/resources/scripts/components/server/users/UsersContainer.tsx +++ b/resources/scripts/components/server/users/UsersContainer.tsx @@ -9,7 +9,6 @@ import FlashMessageRender from '@/components/FlashMessageRender'; import getServerSubusers from '@/api/server/users/getServerSubusers'; import { httpErrorToHuman } from '@/api/http'; import Can from '@/components/elements/Can'; -import ListRefreshIndicator from '@/components/elements/ListRefreshIndicator'; export default () => { const [ loading, setLoading ] = useState(true); @@ -48,7 +47,6 @@ export default () => { return (
- {!subusers.length ?

diff --git a/resources/scripts/easy-peasy.d.ts b/resources/scripts/easy-peasy.d.ts index 939ad54cf..999ea2f56 100644 --- a/resources/scripts/easy-peasy.d.ts +++ b/resources/scripts/easy-peasy.d.ts @@ -1,9 +1,13 @@ // noinspection ES6UnusedImports -import EasyPeasy from 'easy-peasy'; +import EasyPeasy, { Actions, State } from 'easy-peasy'; import { ApplicationStore } from '@/state'; declare module 'easy-peasy' { export function useStoreState( - mapState: (state: ApplicationStore) => Result, + mapState: (state: State) => Result, + ): Result; + + export function useStoreActions( + mapActions: (actions: Actions) => Result, ): Result; } diff --git a/resources/scripts/helpers.ts b/resources/scripts/helpers.ts index 9d531b52c..ad9008d17 100644 --- a/resources/scripts/helpers.ts +++ b/resources/scripts/helpers.ts @@ -9,3 +9,5 @@ export function bytesToHuman (bytes: number): string { } export const bytesToMegabytes = (bytes: number) => Math.floor(bytes / 1000 / 1000); + +export const randomInt = (low: number, high: number) => Math.floor(Math.random() * (high - low) + low); diff --git a/resources/scripts/state/index.ts b/resources/scripts/state/index.ts index ee66d5478..46506eae1 100644 --- a/resources/scripts/state/index.ts +++ b/resources/scripts/state/index.ts @@ -3,12 +3,14 @@ import flashes, { FlashStore } from '@/state/flashes'; import user, { UserStore } from '@/state/user'; import permissions, { GloablPermissionsStore } from '@/state/permissions'; import settings, { SettingsStore } from '@/state/settings'; +import progress, { ProgressStore } from '@/state/progress'; export interface ApplicationStore { permissions: GloablPermissionsStore; flashes: FlashStore; user: UserStore; settings: SettingsStore; + progress: ProgressStore; } const state: ApplicationStore = { @@ -16,6 +18,7 @@ const state: ApplicationStore = { flashes, user, settings, + progress, }; export const store = createStore(state); diff --git a/resources/scripts/state/progress.ts b/resources/scripts/state/progress.ts new file mode 100644 index 000000000..e6d9251f1 --- /dev/null +++ b/resources/scripts/state/progress.ts @@ -0,0 +1,30 @@ +import { action, Action } from 'easy-peasy'; + +export interface ProgressStore { + continuous: boolean; + progress?: number; + + startContinuous: Action; + setProgress: Action; + setComplete: Action; +} + +const progress: ProgressStore = { + continuous: false, + progress: undefined, + + startContinuous: action(state => { + state.continuous = true; + }), + + setProgress: action((state, payload) => { + state.progress = payload; + }), + + setComplete: action(state => { + state.progress = 100; + state.continuous = false; + }), +}; + +export default progress; From 76300209f19f9d4c0ad5f9b575b6cad2f4a86b36 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 10 Apr 2020 12:49:09 -0700 Subject: [PATCH 4/9] Correctly clear the interval for resource use checking --- resources/scripts/components/dashboard/ServerRow.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/resources/scripts/components/dashboard/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index 7789f8125..9903ec17f 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faServer } from '@fortawesome/free-solid-svg-icons/faServer'; import { faEthernet } from '@fortawesome/free-solid-svg-icons/faEthernet'; @@ -21,18 +21,19 @@ const isAlarmState = (current: number, limit: number): boolean => { }; export default ({ server, className }: { server: Server; className: string | undefined }) => { + const interval = useRef(null); const [ stats, setStats ] = useState(null); const getStats = () => getServerResourceUsage(server.uuid).then(data => setStats(data)); useEffect(() => { - let interval: any = null; getStats().then(() => { - interval = setInterval(() => getStats(), 20000); + // @ts-ignore + interval.current = setInterval(() => getStats(), 20000); }); return () => { - interval && clearInterval(interval); + interval.current && clearInterval(interval.current); }; }, []); From b72a770ec939430f8ab4731c48885addd0bc0ce3 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 10 Apr 2020 13:57:24 -0700 Subject: [PATCH 5/9] Don't execute unnecessary HTTP requests when browing a file directory --- .../server/files/FileManagerBreadcrumbs.tsx | 13 ++++--------- .../server/files/FileManagerContainer.tsx | 6 +++--- .../components/server/files/FileObjectRow.tsx | 8 ++++---- resources/scripts/helpers.ts | 2 ++ resources/scripts/routers/ServerRouter.tsx | 2 +- resources/scripts/state/server/files.ts | 5 +++-- 6 files changed, 17 insertions(+), 19 deletions(-) diff --git a/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx b/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx index bbe08149e..5e595a2e5 100644 --- a/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx +++ b/resources/scripts/components/server/files/FileManagerBreadcrumbs.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { ServerContext } from '@/state/server'; -import { NavLink, useParams } from 'react-router-dom'; +import { NavLink } from 'react-router-dom'; +import { cleanDirectoryPath } from '@/helpers'; interface Props { withinFileEditor?: boolean; @@ -8,21 +9,17 @@ interface Props { } export default ({ withinFileEditor, isNewFile }: Props) => { - const { action } = useParams(); const [ file, setFile ] = useState(null); const id = ServerContext.useStoreState(state => state.server.data!.id); const directory = ServerContext.useStoreState(state => state.files.directory); - const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); useEffect(() => { - const parts = window.location.hash.replace(/^#(\/)*/, '/').split('/'); + const parts = cleanDirectoryPath(window.location.hash).split('/'); if (withinFileEditor && !isNewFile) { setFile(parts.pop() || null); } - - setDirectory(parts.join('/')); - }, [ withinFileEditor, isNewFile, setDirectory ]); + }, [ withinFileEditor, isNewFile ]); const breadcrumbs = (): { name: string; path?: string }[] => directory.split('/') .filter(directory => !!directory) @@ -39,7 +36,6 @@ export default ({ withinFileEditor, isNewFile }: Props) => { /home/ setDirectory('/')} className={'px-1 text-neutral-200 no-underline hover:text-neutral-100'} > container @@ -50,7 +46,6 @@ export default ({ withinFileEditor, isNewFile }: Props) => { setDirectory(crumb.path!)} className={'px-1 text-neutral-200 no-underline hover:text-neutral-100'} > {crumb.name} diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index 41adcd8e2..7328a94aa 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -22,20 +22,20 @@ export default () => { const [ loading, setLoading ] = useState(true); const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); const { id } = ServerContext.useStoreState(state => state.server.data!); - const { contents: files, directory } = ServerContext.useStoreState(state => state.files); + const { contents: files } = ServerContext.useStoreState(state => state.files); const { getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files); useEffect(() => { setLoading(true); clearFlashes(); - getDirectoryContents(window.location.hash.replace(/^#(\/)*/, '/')) + getDirectoryContents(window.location.hash) .then(() => setLoading(false)) .catch(error => { console.error(error.message, { error }); addError({ message: httpErrorToHuman(error), key: 'files' }); }); - }, [ directory ]); + }, []); return (

diff --git a/resources/scripts/components/server/files/FileObjectRow.tsx b/resources/scripts/components/server/files/FileObjectRow.tsx index 76eac19bf..c393ae35f 100644 --- a/resources/scripts/components/server/files/FileObjectRow.tsx +++ b/resources/scripts/components/server/files/FileObjectRow.tsx @@ -2,7 +2,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faFileImport } from '@fortawesome/free-solid-svg-icons/faFileImport'; import { faFileAlt } from '@fortawesome/free-solid-svg-icons/faFileAlt'; import { faFolder } from '@fortawesome/free-solid-svg-icons/faFolder'; -import { bytesToHuman } from '@/helpers'; +import { bytesToHuman, cleanDirectoryPath } from '@/helpers'; import differenceInHours from 'date-fns/difference_in_hours'; import format from 'date-fns/format'; import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; @@ -16,7 +16,7 @@ import useRouter from 'use-react-router'; export default ({ file }: { file: FileObject }) => { const directory = ServerContext.useStoreState(state => state.files.directory); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); - const { match } = useRouter(); + const { match, history } = useRouter(); return (
{ `} > { // Don't rely on the onClick to work with the generated URL. Because of the way this @@ -38,7 +38,7 @@ export default ({ file }: { file: FileObject }) => { if (!file.isFile) { e.preventDefault(); - window.location.hash = `#${directory}/${file.name}`; + history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`); setDirectory(`${directory}/${file.name}`); } }} diff --git a/resources/scripts/helpers.ts b/resources/scripts/helpers.ts index ad9008d17..f8cf94848 100644 --- a/resources/scripts/helpers.ts +++ b/resources/scripts/helpers.ts @@ -11,3 +11,5 @@ export function bytesToHuman (bytes: number): string { export const bytesToMegabytes = (bytes: number) => Math.floor(bytes / 1000 / 1000); export const randomInt = (low: number, high: number) => Math.floor(Math.random() * (high - low) + low); + +export const cleanDirectoryPath = (path: string) => path.replace(/(^#\/*)|(\/(\/*))|(^$)/g, '/'); diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index c35614d06..48d3e6db6 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -64,7 +64,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
: - + { - state.directory = payload.length === 0 ? '/' : payload; + state.directory = cleanDirectoryPath(payload) }), }; From fd8b7182e7933fb0b98dceb0f69785a2b24ecf09 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 10 Apr 2020 14:01:36 -0700 Subject: [PATCH 6/9] Merge migrations --- ...4_04_131016_add_table_server_transfers.php | 1 + ..._successful_column_to_server_transfers.php | 32 ------------------- 2 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 database/migrations/2020_04_04_172331_add_successful_column_to_server_transfers.php diff --git a/database/migrations/2020_04_04_131016_add_table_server_transfers.php b/database/migrations/2020_04_04_131016_add_table_server_transfers.php index 1ce37c26d..85c73f46e 100644 --- a/database/migrations/2020_04_04_131016_add_table_server_transfers.php +++ b/database/migrations/2020_04_04_131016_add_table_server_transfers.php @@ -18,6 +18,7 @@ class AddTableServerTransfers extends Migration Schema::create('server_transfers', function (Blueprint $table) { $table->increments('id'); $table->integer('server_id')->unsigned(); + $table->tinyInteger('successful')->unsigned()->default(0); $table->integer('old_node')->unsigned(); $table->integer('new_node')->unsigned(); $table->integer('old_allocation')->unsigned(); diff --git a/database/migrations/2020_04_04_172331_add_successful_column_to_server_transfers.php b/database/migrations/2020_04_04_172331_add_successful_column_to_server_transfers.php deleted file mode 100644 index 83f183abf..000000000 --- a/database/migrations/2020_04_04_172331_add_successful_column_to_server_transfers.php +++ /dev/null @@ -1,32 +0,0 @@ -tinyInteger('successful')->unsigned()->default(0); - }); - } - - /** - * Reverse the migrations. - * - * @return void - */ - public function down() - { - Schema::table('server_transfers', function (Blueprint $table) { - $table->dropColumn('successful'); - }); - } -} From 568328596af7accc2821613e85e3504768f65f56 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 10 Apr 2020 14:02:16 -0700 Subject: [PATCH 7/9] Cleanup migration --- .../2020_04_04_131016_add_table_server_transfers.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/database/migrations/2020_04_04_131016_add_table_server_transfers.php b/database/migrations/2020_04_04_131016_add_table_server_transfers.php index 85c73f46e..ae0a57ca9 100644 --- a/database/migrations/2020_04_04_131016_add_table_server_transfers.php +++ b/database/migrations/2020_04_04_131016_add_table_server_transfers.php @@ -13,8 +13,6 @@ class AddTableServerTransfers extends Migration */ public function up() { - Schema::dropIfExists('server_transfers'); - Schema::create('server_transfers', function (Blueprint $table) { $table->increments('id'); $table->integer('server_id')->unsigned(); @@ -26,10 +24,8 @@ class AddTableServerTransfers extends Migration $table->string('old_additional_allocations')->nullable(); $table->string('new_additional_allocations')->nullable(); $table->timestamps(); - }); - Schema::table('server_transfers', function (Blueprint $table) { - $table->foreign('server_id')->references('id')->on('servers'); + $table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade'); }); } From 2ac82af25ad66ccbe2c2b91fa776f89dcfa523ce Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 10 Apr 2020 14:09:57 -0700 Subject: [PATCH 8/9] Fix migration to not require beta versions of MariaDB --- ..._merge_permissions_table_into_subusers.php | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php b/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php index 1568ef726..7cd0c5fce 100644 --- a/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php +++ b/database/migrations/2020_03_22_163911_merge_permissions_table_into_subusers.php @@ -18,15 +18,21 @@ class MergePermissionsTableIntoSubusers extends Migration $table->json('permissions')->nullable()->after('server_id'); }); - DB::statement(' - UPDATE subusers as s - LEFT JOIN ( - SELECT subuser_id, JSON_ARRAYAGG(permission) as permissions - FROM permissions - GROUP BY subuser_id - ) as p ON p.subuser_id = s.id - SET s.permissions = p.permissions - '); + $cursor = DB::table('permissions') + ->select(['subuser_id']) + ->selectRaw('GROUP_CONCAT(permission) as permissions') + ->from('permissions') + ->groupBy(['subuser_id']) + ->cursor(); + + DB::transaction(function () use (&$cursor) { + $cursor->each(function ($datum) { + DB::update('UPDATE subusers SET permissions = ? WHERE id = ?', [ + json_encode(explode(',', $datum->permissions)), + $datum->subuser_id, + ]); + }); + }); } /** From 7557dddf49e9938e8e33eed1d6858a2a7bc0a28e Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Fri, 10 Apr 2020 15:15:38 -0700 Subject: [PATCH 9/9] Store node daemon tokens in an encrypted manner --- .../Admin/Nodes/NodeViewController.php | 4 +- .../Admin/StatisticsController.php | 2 +- .../Servers/ServerTransferController.php | 2 +- .../Controllers/Daemon/ActionController.php | 107 ------------ .../Controllers/Daemon/PackController.php | 73 --------- app/Http/Kernel.php | 2 - .../Api/Daemon/DaemonAuthenticate.php | 39 +++-- app/Http/Middleware/DaemonAuthenticate.php | 69 -------- app/Models/Node.php | 45 +++-- app/Providers/RouteServiceProvider.php | 4 - app/Repositories/Daemon/BaseRepository.php | 154 ------------------ app/Repositories/Daemon/CommandRepository.php | 25 --- .../Daemon/ConfigurationRepository.php | 46 ------ app/Repositories/Daemon/FileRepository.php | 104 ------------ app/Repositories/Daemon/PowerRepository.php | 36 ---- app/Repositories/Daemon/ServerRepository.php | 134 --------------- app/Repositories/Eloquent/NodeRepository.php | 2 +- .../Wings/DaemonConfigurationRepository.php | 18 ++ app/Repositories/Wings/DaemonRepository.php | 2 +- app/Services/Nodes/NodeCreationService.php | 26 +-- app/Services/Nodes/NodeJWTService.php | 2 +- app/Services/Nodes/NodeUpdateService.php | 45 +++-- database/factories/ModelFactory.php | 9 +- ...4_store_node_tokens_as_encrypted_value.php | 84 ++++++++++ resources/views/admin/nodes/index.blade.php | 2 +- routes/daemon.php | 13 -- 26 files changed, 222 insertions(+), 827 deletions(-) delete mode 100644 app/Http/Controllers/Daemon/ActionController.php delete mode 100644 app/Http/Controllers/Daemon/PackController.php delete mode 100644 app/Http/Middleware/DaemonAuthenticate.php delete mode 100644 app/Repositories/Daemon/BaseRepository.php delete mode 100644 app/Repositories/Daemon/CommandRepository.php delete mode 100644 app/Repositories/Daemon/ConfigurationRepository.php delete mode 100644 app/Repositories/Daemon/FileRepository.php delete mode 100644 app/Repositories/Daemon/PowerRepository.php delete mode 100644 app/Repositories/Daemon/ServerRepository.php create mode 100644 database/migrations/2020_04_10_141024_store_node_tokens_as_encrypted_value.php delete mode 100644 routes/daemon.php diff --git a/app/Http/Controllers/Admin/Nodes/NodeViewController.php b/app/Http/Controllers/Admin/Nodes/NodeViewController.php index 017706351..ba9e2e947 100644 --- a/app/Http/Controllers/Admin/Nodes/NodeViewController.php +++ b/app/Http/Controllers/Admin/Nodes/NodeViewController.php @@ -148,8 +148,8 @@ class NodeViewController extends Controller public function servers(Request $request, Node $node) { $this->plainInject([ - 'node' => Collection::wrap($node->makeVisible('daemonSecret')) - ->only(['scheme', 'fqdn', 'daemonListen', 'daemonSecret']), + 'node' => Collection::wrap($node->makeVisible(['daemon_token_id', 'daemon_token'])) + ->only(['scheme', 'fqdn', 'daemonListen', 'daemon_token_id', 'daemon_token']), ]); return $this->view->make('admin.nodes.view.servers', [ diff --git a/app/Http/Controllers/Admin/StatisticsController.php b/app/Http/Controllers/Admin/StatisticsController.php index 1ae807565..9e6ff1ad2 100644 --- a/app/Http/Controllers/Admin/StatisticsController.php +++ b/app/Http/Controllers/Admin/StatisticsController.php @@ -67,7 +67,7 @@ class StatisticsController extends Controller $tokens = []; foreach ($nodes as $node) { - $tokens[$node->id] = $node->daemonSecret; + $tokens[$node->id] = decrypt($node->daemon_token); } $this->injectJavascript([ diff --git a/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php b/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php index 1d2e723ee..bd8827a6e 100644 --- a/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php +++ b/app/Http/Controllers/Api/Remote/Servers/ServerTransferController.php @@ -145,7 +145,7 @@ class ServerTransferController extends Controller ->canOnlyBeUsedAfter($now->getTimestamp()) ->expiresAt($now->addMinutes(15)->getTimestamp()) ->relatedTo($server->uuid, true) - ->getToken($signer, new Key($server->node->daemonSecret)); + ->getToken($signer, new Key($server->node->getDecryptedKey())); // On the daemon transfer repository, make sure to set the node after the server // because setServer() tells the repository to use the server's node and not the one diff --git a/app/Http/Controllers/Daemon/ActionController.php b/app/Http/Controllers/Daemon/ActionController.php deleted file mode 100644 index eb77b4b9c..000000000 --- a/app/Http/Controllers/Daemon/ActionController.php +++ /dev/null @@ -1,107 +0,0 @@ -eventDispatcher = $eventDispatcher; - $this->repository = $repository; - } - - /** - * Handles install toggle request from daemon. - * - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\JsonResponse - * - * @throws \Pterodactyl\Exceptions\Model\DataValidationException - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function markInstall(Request $request): JsonResponse - { - try { - /** @var \Pterodactyl\Models\Server $server */ - $server = $this->repository->findFirstWhere([ - 'uuid' => $request->input('server'), - ]); - } catch (RecordNotFoundException $exception) { - return JsonResponse::create([ - 'error' => 'No server by that ID was found on the system.', - ], Response::HTTP_UNPROCESSABLE_ENTITY); - } - - if (! $server->relationLoaded('node')) { - $server->load('node'); - } - - $hmac = $request->input('signed'); - $status = $request->input('installed'); - - if (! hash_equals(base64_decode($hmac), hash_hmac('sha256', $server->uuid, $server->getRelation('node')->daemonSecret, true))) { - return JsonResponse::create([ - 'error' => 'Signed HMAC was invalid.', - ], Response::HTTP_FORBIDDEN); - } - - $this->repository->update($server->id, [ - 'installed' => ($status === 'installed') ? 1 : 2, - ], true, true); - - // Only fire event if server installed successfully. - if ($status === 'installed') { - $this->eventDispatcher->dispatch(new ServerInstalled($server)); - } - - // Don't use a 204 here, the daemon is hard-checking for a 200 code. - return JsonResponse::create([]); - } - - /** - * Handles configuration data request from daemon. - * - * @param \Illuminate\Http\Request $request - * @param string $token - * @return \Illuminate\Http\JsonResponse|\Illuminate\Http\Response - */ - public function configuration(Request $request, $token) - { - $nodeId = Cache::pull('Node:Configuration:' . $token); - if (is_null($nodeId)) { - return response()->json(['error' => 'token_invalid'], 403); - } - - $node = Node::findOrFail($nodeId); - - // Manually as getConfigurationAsJson() returns it in correct format already - return $node->getJsonConfiguration(); - } -} diff --git a/app/Http/Controllers/Daemon/PackController.php b/app/Http/Controllers/Daemon/PackController.php deleted file mode 100644 index 45c38bf6c..000000000 --- a/app/Http/Controllers/Daemon/PackController.php +++ /dev/null @@ -1,73 +0,0 @@ -. - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ - -namespace Pterodactyl\Http\Controllers\Daemon; - -use Storage; -use Pterodactyl\Models; -use Illuminate\Http\Request; -use Pterodactyl\Http\Controllers\Controller; - -class PackController extends Controller -{ - /** - * Pulls an install pack archive from the system. - * - * @param \Illuminate\Http\Request $request - * @param string $uuid - * @return \Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\BinaryFileResponse - */ - public function pull(Request $request, $uuid) - { - $pack = Models\Pack::where('uuid', $uuid)->first(); - - if (! $pack) { - return response()->json(['error' => 'No such pack.'], 404); - } - - if (! Storage::exists('packs/' . $pack->uuid . '/archive.tar.gz')) { - return response()->json(['error' => 'There is no archive available for this pack.'], 503); - } - - return response()->download(storage_path('app/packs/' . $pack->uuid . '/archive.tar.gz')); - } - - /** - * Returns the hash information for a pack. - * - * @param \Illuminate\Http\Request $request - * @param string $uuid - * @return \Illuminate\Http\JsonResponse - */ - public function hash(Request $request, $uuid) - { - $pack = Models\Pack::where('uuid', $uuid)->first(); - - if (! $pack) { - return response()->json(['error' => 'No such pack.'], 404); - } - - if (! Storage::exists('packs/' . $pack->uuid . '/archive.tar.gz')) { - return response()->json(['error' => 'There is no archive available for this pack.'], 503); - } - - return response()->json([ - 'archive.tar.gz' => sha1_file(storage_path('app/packs/' . $pack->uuid . '/archive.tar.gz')), - ]); - } - - /** - * Pulls an update pack archive from the system. - * - * @param \Illuminate\Http\Request $request - */ - public function pullUpdate(Request $request) - { - } -} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index e1e7f0082..c6d537a26 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -38,7 +38,6 @@ use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull; use Pterodactyl\Http\Middleware\Api\Client\SubstituteClientApiBindings; use Pterodactyl\Http\Middleware\Api\Application\AuthenticateApplicationUser; -use Pterodactyl\Http\Middleware\DaemonAuthenticate as OldDaemonAuthenticate; class Kernel extends HttpKernel { @@ -107,7 +106,6 @@ class Kernel extends HttpKernel 'server' => AccessingValidServer::class, 'subuser.auth' => AuthenticateAsSubuser::class, 'admin' => AdminAuthenticate::class, - 'daemon-old' => OldDaemonAuthenticate::class, 'csrf' => VerifyCsrfToken::class, 'throttle' => ThrottleRequests::class, 'can' => Authorize::class, diff --git a/app/Http/Middleware/Api/Daemon/DaemonAuthenticate.php b/app/Http/Middleware/Api/Daemon/DaemonAuthenticate.php index f956302a4..686d7a0fc 100644 --- a/app/Http/Middleware/Api/Daemon/DaemonAuthenticate.php +++ b/app/Http/Middleware/Api/Daemon/DaemonAuthenticate.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Http\Middleware\Api\Daemon; use Closure; use Illuminate\Http\Request; +use Illuminate\Contracts\Encryption\Encrypter; use Symfony\Component\HttpKernel\Exception\HttpException; use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; use Pterodactyl\Exceptions\Repository\RecordNotFoundException; @@ -25,14 +26,21 @@ class DaemonAuthenticate 'daemon.configuration', ]; + /** + * @var \Illuminate\Contracts\Encryption\Encrypter + */ + private $encrypter; + /** * DaemonAuthenticate constructor. * + * @param \Illuminate\Contracts\Encryption\Encrypter $encrypter * @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository */ - public function __construct(NodeRepositoryInterface $repository) + public function __construct(Encrypter $encrypter, NodeRepositoryInterface $repository) { $this->repository = $repository; + $this->encrypter = $encrypter; } /** @@ -50,20 +58,31 @@ class DaemonAuthenticate return $next($request); } - $token = $request->bearerToken(); - - if (is_null($token)) { - throw new HttpException(401, null, null, ['WWW-Authenticate' => 'Bearer']); + if (is_null($bearer = $request->bearerToken())) { + throw new HttpException( + 401, 'Access this this endpoint must include an Authorization header.', null, ['WWW-Authenticate' => 'Bearer'] + ); } + [$identifier, $token] = explode('.', $bearer); + try { - $node = $this->repository->findFirstWhere([['daemonSecret', '=', $token]]); + /** @var \Pterodactyl\Models\Node $node */ + $node = $this->repository->findFirstWhere([ + 'daemon_token_id' => $identifier, + ]); + + if (hash_equals((string) $this->encrypter->decrypt($node->daemon_token), $token)) { + $request->attributes->set('node', $node); + + return $next($request); + } } catch (RecordNotFoundException $exception) { - throw new AccessDeniedHttpException; + // Do nothing, we don't want to expose a node not existing at all. } - $request->attributes->set('node', $node); - - return $next($request); + throw new AccessDeniedHttpException( + 'You are not authorized to access this resource.' + ); } } diff --git a/app/Http/Middleware/DaemonAuthenticate.php b/app/Http/Middleware/DaemonAuthenticate.php deleted file mode 100644 index cb132b999..000000000 --- a/app/Http/Middleware/DaemonAuthenticate.php +++ /dev/null @@ -1,69 +0,0 @@ -. - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ - -namespace Pterodactyl\Http\Middleware; - -use Closure; -use Illuminate\Http\Request; -use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; -use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; - -class DaemonAuthenticate -{ - /** - * An array of route names to not apply this middleware to. - * - * @var array - */ - private $except = [ - 'daemon.configuration', - ]; - - /** - * @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface - */ - private $repository; - - /** - * Create a new filter instance. - * - * @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository - * @deprecated - */ - public function __construct(NodeRepositoryInterface $repository) - { - $this->repository = $repository; - } - - /** - * Handle an incoming request. - * - * @param \Illuminate\Http\Request $request - * @param \Closure $next - * @return mixed - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException - */ - public function handle(Request $request, Closure $next) - { - if (in_array($request->route()->getName(), $this->except)) { - return $next($request); - } - - if (! $request->header('X-Access-Node')) { - throw new AccessDeniedHttpException; - } - - $node = $this->repository->findFirstWhere(['daemonSecret' => $request->header('X-Access-Node')]); - $request->attributes->set('node', $node); - - return $next($request); - } -} diff --git a/app/Models/Node.php b/app/Models/Node.php index 950186fde..7faccc378 100644 --- a/app/Models/Node.php +++ b/app/Models/Node.php @@ -3,11 +3,14 @@ namespace Pterodactyl\Models; use Symfony\Component\Yaml\Yaml; +use Illuminate\Container\Container; use Illuminate\Notifications\Notifiable; use Pterodactyl\Models\Traits\Searchable; +use Illuminate\Contracts\Encryption\Encrypter; /** * @property int $id + * @property string $uuid * @property bool $public * @property string $name * @property string $description @@ -21,7 +24,8 @@ use Pterodactyl\Models\Traits\Searchable; * @property int $disk * @property int $disk_overallocate * @property int $upload_size - * @property string $daemonSecret + * @property string $daemon_token_id + * @property string $daemon_token * @property int $daemonListen * @property int $daemonSFTP * @property string $daemonBase @@ -43,7 +47,8 @@ class Node extends Model */ const RESOURCE_NAME = 'node'; - const DAEMON_SECRET_LENGTH = 36; + const DAEMON_TOKEN_ID_LENGTH = 16; + const DAEMON_TOKEN_LENGTH = 64; /** * The table associated with the model. @@ -57,7 +62,7 @@ class Node extends Model * * @var array */ - protected $hidden = ['daemonSecret']; + protected $hidden = ['daemon_token_id', 'daemon_token']; /** * Cast values to correct type. @@ -84,8 +89,7 @@ class Node extends Model 'public', 'name', 'location_id', 'fqdn', 'scheme', 'behind_proxy', 'memory', 'memory_overallocate', 'disk', - 'disk_overallocate', 'upload_size', - 'daemonSecret', 'daemonBase', + 'disk_overallocate', 'upload_size', 'daemonBase', 'daemonSFTP', 'daemonListen', 'description', 'maintenance_mode', ]; @@ -153,12 +157,15 @@ class Node extends Model /** * Returns the configuration as an array. * - * @return string + * @return array */ - private function getConfiguration() + public function getConfiguration() { return [ 'debug' => false, + 'uuid' => $this->uuid, + 'token_id' => $this->daemon_token_id, + 'token' => Container::getInstance()->make(Encrypter::class)->decrypt($this->daemon_token), 'api' => [ 'host' => '0.0.0.0', 'port' => $this->daemonListen, @@ -202,7 +209,6 @@ class Node extends Model 'check_interval' => 100, ], 'remote' => route('index'), - 'token' => $this->daemonSecret, ]; } @@ -211,17 +217,32 @@ class Node extends Model * * @return string */ - public function getYamlConfiguration() { + public function getYamlConfiguration() + { return Yaml::dump($this->getConfiguration(), 4, 2); } - /** + /** * Returns the configuration in JSON format. * + * @param bool $pretty + * @return string + */ + public function getJsonConfiguration(bool $pretty = false) + { + return json_encode($this->getConfiguration(), $pretty ? JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT : JSON_UNESCAPED_SLASHES); + } + + /** + * Helper function to return the decrypted key for a node. + * * @return string */ - public function getJsonConfiguration(bool $pretty = false) { - return json_encode($this->getConfiguration(), $pretty ? JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT : JSON_UNESCAPED_SLASHES); + public function getDecryptedKey(): string + { + return (string) Container::getInstance()->make(Encrypter::class)->decrypt( + $this->daemon_token + ); } /** diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 2ee187c69..4e6099b9b 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -49,9 +49,5 @@ class RouteServiceProvider extends ServiceProvider Route::middleware(['daemon'])->prefix('/api/remote') ->namespace($this->namespace . '\Api\Remote') ->group(base_path('routes/api-remote.php')); - - Route::middleware(['web', 'daemon-old'])->prefix('/daemon') - ->namespace($this->namespace . '\Daemon') - ->group(base_path('routes/daemon.php')); } } diff --git a/app/Repositories/Daemon/BaseRepository.php b/app/Repositories/Daemon/BaseRepository.php deleted file mode 100644 index 6e38c1cad..000000000 --- a/app/Repositories/Daemon/BaseRepository.php +++ /dev/null @@ -1,154 +0,0 @@ -app = $app; - $this->nodeRepository = $nodeRepository; - } - - /** - * Set the node model to be used for this daemon connection. - * - * @param \Pterodactyl\Models\Node $node - * @return $this - */ - public function setNode(Node $node) - { - $this->node = $node; - - return $this; - } - - /** - * Return the node model being used. - * - * @return \Pterodactyl\Models\Node|null - */ - public function getNode() - { - return $this->node; - } - - /** - * Set the Server model to use when requesting information from the Daemon. - * - * @param \Pterodactyl\Models\Server $server - * @return $this - */ - public function setServer(Server $server) - { - $this->server = $server; - - return $this; - } - - /** - * Return the Server model. - * - * @return \Pterodactyl\Models\Server|null - */ - public function getServer() - { - return $this->server; - } - - /** - * Set the token to be used in the X-Access-Token header for requests to the daemon. - * - * @param string $token - * @return $this - */ - public function setToken(string $token) - { - $this->token = $token; - - return $this; - } - - /** - * Return the access token being used for requests. - * - * @return string|null - */ - public function getToken() - { - return $this->token; - } - - /** - * Return an instance of the Guzzle HTTP Client to be used for requests. - * - * @param array $headers - * @return \GuzzleHttp\Client - */ - public function getHttpClient(array $headers = []): Client - { - // If no node is set, load the relationship onto the Server model - // and pass that to the setNode function. - if (! $this->getNode() instanceof Node) { - if (! $this->getServer() instanceof Server) { - throw new RuntimeException('An instance of ' . Node::class . ' or ' . Server::class . ' must be set on this repository in order to return a client.'); - } - - $this->getServer()->loadMissing('node'); - $this->setNode($this->getServer()->getRelation('node')); - } - - if ($this->getServer() instanceof Server) { - $headers['X-Access-Server'] = $this->getServer()->uuid; - } - - $headers['X-Access-Token'] = $this->getToken() ?? $this->getNode()->daemonSecret; - - return new Client([ - 'verify' => config('app.env') === 'production', - 'base_uri' => sprintf('%s://%s:%s/v1/', $this->getNode()->scheme, $this->getNode()->fqdn, $this->getNode()->daemonListen), - 'timeout' => config('pterodactyl.guzzle.timeout'), - 'connect_timeout' => config('pterodactyl.guzzle.connect_timeout'), - 'headers' => $headers, - ]); - } -} diff --git a/app/Repositories/Daemon/CommandRepository.php b/app/Repositories/Daemon/CommandRepository.php deleted file mode 100644 index cd123cd89..000000000 --- a/app/Repositories/Daemon/CommandRepository.php +++ /dev/null @@ -1,25 +0,0 @@ -getHttpClient()->request('POST', 'server/command', [ - 'json' => [ - 'command' => $command, - ], - ]); - } -} diff --git a/app/Repositories/Daemon/ConfigurationRepository.php b/app/Repositories/Daemon/ConfigurationRepository.php deleted file mode 100644 index 3905335a5..000000000 --- a/app/Repositories/Daemon/ConfigurationRepository.php +++ /dev/null @@ -1,46 +0,0 @@ -getNode(); - $structure = [ - 'web' => [ - 'listen' => $node->daemonListen, - 'ssl' => [ - 'enabled' => (! $node->behind_proxy && $node->scheme === 'https'), - ], - ], - 'sftp' => [ - 'path' => $node->daemonBase, - 'port' => $node->daemonSFTP, - ], - 'remote' => [ - 'base' => config('app.url'), - ], - 'uploads' => [ - 'size_limit' => $node->upload_size, - ], - 'keys' => [ - $node->daemonSecret, - ], - ]; - - return $this->getHttpClient()->request('PATCH', 'config', [ - 'json' => array_merge($structure, $overrides), - ]); - } -} diff --git a/app/Repositories/Daemon/FileRepository.php b/app/Repositories/Daemon/FileRepository.php deleted file mode 100644 index 7c01f8dc8..000000000 --- a/app/Repositories/Daemon/FileRepository.php +++ /dev/null @@ -1,104 +0,0 @@ -getHttpClient()->request('GET', sprintf( - 'server/file/stat/%s', - rawurlencode($file['dirname'] . $file['basename']) - )); - - return json_decode($response->getBody()); - } - - /** - * Return the contents of a given file if it can be edited in the Panel. - * - * @param string $path - * @return string - * - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function getContent(string $path): string - { - $file = str_replace('\\', '/', pathinfo($path)); - $file['dirname'] = in_array($file['dirname'], ['.', './', '/']) ? null : trim($file['dirname'], '/') . '/'; - - $response = $this->getHttpClient()->request('GET', sprintf( - 'server/file/f/%s', - rawurlencode($file['dirname'] . $file['basename']) - )); - - return object_get(json_decode($response->getBody()), 'content'); - } - - /** - * Save new contents to a given file. - * - * @param string $path - * @param string $content - * @return \Psr\Http\Message\ResponseInterface - * - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function putContent(string $path, string $content): ResponseInterface - { - $file = str_replace('\\', '/', pathinfo($path)); - $file['dirname'] = in_array($file['dirname'], ['.', './', '/']) ? null : trim($file['dirname'], '/') . '/'; - - return $this->getHttpClient()->request('POST', 'server/file/save', [ - 'json' => [ - 'path' => rawurlencode($file['dirname'] . $file['basename']), - 'content' => $content, - ], - ]); - } - - /** - * Return a directory listing for a given path. - * - * @param string $path - * @return array - * - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function getDirectory(string $path): array - { - $response = $this->getHttpClient()->request('GET', sprintf('server/directory/%s', rawurlencode($path))); - - return json_decode($response->getBody()); - } - - /** - * Creates a new directory for the server in the given $path. - * - * @param string $name - * @param string $path - * @return \Psr\Http\Message\ResponseInterface - * - * @throws \RuntimeException - */ - public function createDirectory(string $name, string $path): ResponseInterface - { - throw new RuntimeException('Not implemented.'); - } -} diff --git a/app/Repositories/Daemon/PowerRepository.php b/app/Repositories/Daemon/PowerRepository.php deleted file mode 100644 index d7ce8d5e5..000000000 --- a/app/Repositories/Daemon/PowerRepository.php +++ /dev/null @@ -1,36 +0,0 @@ -getHttpClient()->request('PUT', 'server/power', [ - 'json' => [ - 'action' => $signal, - ], - ]); - default: - throw new InvalidPowerSignalException('The signal "' . $signal . '" is not defined and could not be processed.'); - } - } -} diff --git a/app/Repositories/Daemon/ServerRepository.php b/app/Repositories/Daemon/ServerRepository.php deleted file mode 100644 index f1bd445fc..000000000 --- a/app/Repositories/Daemon/ServerRepository.php +++ /dev/null @@ -1,134 +0,0 @@ - $value) { - $structure[$key] = value($value); - } - - return $this->getHttpClient()->request('POST', 'servers', [ - 'json' => $structure, - ]); - } - - /** - * Update server details on the daemon. - * - * @param array $data - * @return \Psr\Http\Message\ResponseInterface - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function update(array $data): ResponseInterface - { - return $this->getHttpClient()->request('PATCH', 'server', [ - 'json' => $data, - ]); - } - - /** - * Mark a server to be reinstalled on the system. - * - * @param array|null $data - * @return \Psr\Http\Message\ResponseInterface - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function reinstall(array $data = null): ResponseInterface - { - return $this->getHttpClient()->request('POST', 'server/reinstall', [ - 'json' => $data ?? [], - ]); - } - - /** - * Mark a server as needing a container rebuild the next time the server is booted. - * - * @return \Psr\Http\Message\ResponseInterface - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function rebuild(): ResponseInterface - { - return $this->getHttpClient()->request('POST', 'server/rebuild'); - } - - /** - * Suspend a server on the daemon. - * - * @return \Psr\Http\Message\ResponseInterface - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function suspend(): ResponseInterface - { - return $this->getHttpClient()->request('POST', 'server/suspend'); - } - - /** - * Un-suspend a server on the daemon. - * - * @return \Psr\Http\Message\ResponseInterface - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function unsuspend(): ResponseInterface - { - return $this->getHttpClient()->request('POST', 'server/unsuspend'); - } - - /** - * Delete a server on the daemon. - * - * @return \Psr\Http\Message\ResponseInterface - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function delete(): ResponseInterface - { - return $this->getHttpClient()->request('DELETE', 'servers'); - } - - /** - * Return details on a specific server. - * - * @return \Psr\Http\Message\ResponseInterface - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function details(): ResponseInterface - { - return $this->getHttpClient()->request('GET', 'server'); - } - - /** - * Revoke an access key on the daemon before the time is expired. - * - * @param string|array $key - * @return \Psr\Http\Message\ResponseInterface - * - * @throws \GuzzleHttp\Exception\GuzzleException - */ - public function revokeAccessKey($key): ResponseInterface - { - if (is_array($key)) { - return $this->getHttpClient()->request('POST', 'keys/batch-delete', [ - 'json' => ['keys' => $key], - ]); - } - - Assert::stringNotEmpty($key, 'First argument passed to revokeAccessKey must be a non-empty string or array, received %s.'); - - return $this->getHttpClient()->request('DELETE', 'keys/' . $key); - } -} diff --git a/app/Repositories/Eloquent/NodeRepository.php b/app/Repositories/Eloquent/NodeRepository.php index 6f317bc81..2385c9109 100644 --- a/app/Repositories/Eloquent/NodeRepository.php +++ b/app/Repositories/Eloquent/NodeRepository.php @@ -183,7 +183,7 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa public function getNodeWithResourceUsage(int $node_id): Node { $instance = $this->getBuilder() - ->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemonSecret', 'nodes.daemonListen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate']) + ->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemon_token', 'nodes.daemonListen', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate']) ->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk') ->leftJoin('servers', 'servers.node_id', '=', 'nodes.id') ->where('nodes.id', $node_id); diff --git a/app/Repositories/Wings/DaemonConfigurationRepository.php b/app/Repositories/Wings/DaemonConfigurationRepository.php index 0579bcf0b..90b504772 100644 --- a/app/Repositories/Wings/DaemonConfigurationRepository.php +++ b/app/Repositories/Wings/DaemonConfigurationRepository.php @@ -23,4 +23,22 @@ class DaemonConfigurationRepository extends DaemonRepository return json_decode($response->getBody()->__toString(), true); } + + /** + * Updates the configuration information for a daemon. + * + * @param array $attributes + * @return \Psr\Http\Message\ResponseInterface + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function update(array $attributes = []) + { + try { + return $this->getHttpClient()->post( + '/api/update', array_merge($this->node->getConfiguration(), $attributes) + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } } diff --git a/app/Repositories/Wings/DaemonRepository.php b/app/Repositories/Wings/DaemonRepository.php index b2535b3a9..4ebe249cf 100644 --- a/app/Repositories/Wings/DaemonRepository.php +++ b/app/Repositories/Wings/DaemonRepository.php @@ -79,7 +79,7 @@ abstract class DaemonRepository 'timeout' => config('pterodactyl.guzzle.timeout'), 'connect_timeout' => config('pterodactyl.guzzle.connect_timeout'), 'headers' => array_merge($headers, [ - 'Authorization' => 'Bearer ' . $this->node->daemonSecret, + 'Authorization' => 'Bearer ' . $this->node->getDecryptedKey(), 'Accept' => 'application/json', 'Content-Type' => 'application/json', ]), diff --git a/app/Services/Nodes/NodeCreationService.php b/app/Services/Nodes/NodeCreationService.php index 889e81a20..5090ba23c 100644 --- a/app/Services/Nodes/NodeCreationService.php +++ b/app/Services/Nodes/NodeCreationService.php @@ -1,33 +1,34 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Services\Nodes; +use Illuminate\Support\Str; +use Pterodactyl\Models\Node; +use Illuminate\Encryption\Encrypter; use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; class NodeCreationService { - const DAEMON_SECRET_LENGTH = 36; - /** * @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface */ protected $repository; + /** + * @var \Illuminate\Encryption\Encrypter + */ + private $encrypter; + /** * CreationService constructor. * + * @param \Illuminate\Encryption\Encrypter $encrypter * @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository */ - public function __construct(NodeRepositoryInterface $repository) + public function __construct(Encrypter $encrypter, NodeRepositoryInterface $repository) { $this->repository = $repository; + $this->encrypter = $encrypter; } /** @@ -40,8 +41,9 @@ class NodeCreationService */ public function handle(array $data) { - $data['daemonSecret'] = str_random(self::DAEMON_SECRET_LENGTH); + $data['daemon_token'] = Str::random(Node::DAEMON_TOKEN_LENGTH); + $data['daemon_token_id'] = $this->encrypter->encrypt(Str::random(Node::DAEMON_TOKEN_ID_LENGTH)); - return $this->repository->create($data); + return $this->repository->create($data, true, true); } } diff --git a/app/Services/Nodes/NodeJWTService.php b/app/Services/Nodes/NodeJWTService.php index bb26527fd..6c9dd7577 100644 --- a/app/Services/Nodes/NodeJWTService.php +++ b/app/Services/Nodes/NodeJWTService.php @@ -69,6 +69,6 @@ class NodeJWTService return $builder ->withClaim('unique_id', Str::random(16)) - ->getToken($signer, new Key($node->daemonSecret)); + ->getToken($signer, new Key($node->getDecryptedKey())); } } diff --git a/app/Services/Nodes/NodeUpdateService.php b/app/Services/Nodes/NodeUpdateService.php index e8adabe48..8ce610df9 100644 --- a/app/Services/Nodes/NodeUpdateService.php +++ b/app/Services/Nodes/NodeUpdateService.php @@ -2,12 +2,15 @@ namespace Pterodactyl\Services\Nodes; +use Illuminate\Support\Str; use Pterodactyl\Models\Node; use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\RequestException; use Illuminate\Database\ConnectionInterface; +use Illuminate\Contracts\Encryption\Encrypter; use Pterodactyl\Repositories\Daemon\ConfigurationRepository; use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; +use Pterodactyl\Repositories\Wings\DaemonConfigurationRepository; use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Exceptions\Service\Node\ConfigurationNotPersistedException; @@ -18,31 +21,39 @@ class NodeUpdateService */ private $connection; - /** - * @var \Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface - */ - private $configRepository; - /** * @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface */ private $repository; + /** + * @var \Pterodactyl\Repositories\Wings\DaemonConfigurationRepository + */ + private $configurationRepository; + + /** + * @var \Illuminate\Contracts\Encryption\Encrypter + */ + private $encrypter; + /** * UpdateService constructor. * * @param \Illuminate\Database\ConnectionInterface $connection - * @param \Pterodactyl\Repositories\Daemon\ConfigurationRepository $configurationRepository + * @param \Illuminate\Contracts\Encryption\Encrypter $encrypter + * @param \Pterodactyl\Repositories\Wings\DaemonConfigurationRepository $configurationRepository * @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository */ public function __construct( ConnectionInterface $connection, - ConfigurationRepository $configurationRepository, + Encrypter $encrypter, + DaemonConfigurationRepository $configurationRepository, NodeRepositoryInterface $repository ) { $this->connection = $connection; - $this->configRepository = $configurationRepository; $this->repository = $repository; + $this->configurationRepository = $configurationRepository; + $this->encrypter = $encrypter; } /** @@ -58,13 +69,14 @@ class NodeUpdateService * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException * @throws \Pterodactyl\Exceptions\Service\Node\ConfigurationNotPersistedException - * - * @throws \GuzzleHttp\Exception\GuzzleException */ public function handle(Node $node, array $data, bool $resetToken = false) { if ($resetToken) { - $data['daemonSecret'] = str_random(Node::DAEMON_SECRET_LENGTH); + $data['daemon_token'] = Str::random(Node::DAEMON_TOKEN_LENGTH); + $data['daemon_token_id'] = $this->encrypter->encrypt( + Str::random(Node::DAEMON_TOKEN_ID_LENGTH) + ); } $this->connection->beginTransaction(); @@ -77,14 +89,15 @@ class NodeUpdateService // We need to clone the new model and set it's authentication token to be the // old one so we can connect. Then we will pass the new token through as an // override on the call. - $cloned = $updatedModel->replicate(['daemonSecret']); - $cloned->setAttribute('daemonSecret', $node->getAttribute('daemonSecret')); + $cloned = $updatedModel->replicate(['daemon_token']); + $cloned->setAttribute('daemon_token', $node->getAttribute('daemon_token')); - $this->configRepository->setNode($cloned)->update([ - 'keys' => [$data['daemonSecret']], + $this->configurationRepository->setNode($cloned)->update([ + 'daemon_token_id' => $updatedModel->daemon_token_id, + 'daemon_token' => $updatedModel->getDecryptedKey(), ]); } else { - $this->configRepository->setNode($updatedModel)->update(); + $this->configurationRepository->setNode($updatedModel)->update(); } $this->connection->commit(); diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index 8866fa555..3b2d3e728 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -1,7 +1,10 @@ define(Pterodactyl\Models\Location::class, function (Faker $faker) { $factory->define(Pterodactyl\Models\Node::class, function (Faker $faker) { return [ 'id' => $faker->unique()->randomNumber(), + 'uuid' => Uuid::uuid4()->toString(), 'public' => true, 'name' => $faker->firstName, 'fqdn' => $faker->ipv4, @@ -90,10 +94,11 @@ $factory->define(Pterodactyl\Models\Node::class, function (Faker $faker) { 'disk' => 10240, 'disk_overallocate' => 0, 'upload_size' => 100, - 'daemonSecret' => $faker->uuid, + 'daemon_token_id' => Str::random(Node::DAEMON_TOKEN_ID_LENGTH), + 'daemon_token' => Str::random(Node::DAEMON_TOKEN_LENGTH), 'daemonListen' => 8080, 'daemonSFTP' => 2022, - 'daemonBase' => '/srv/daemon', + 'daemonBase' => '/srv/daemon-data', ]; }); diff --git a/database/migrations/2020_04_10_141024_store_node_tokens_as_encrypted_value.php b/database/migrations/2020_04_10_141024_store_node_tokens_as_encrypted_value.php new file mode 100644 index 000000000..22c53397b --- /dev/null +++ b/database/migrations/2020_04_10_141024_store_node_tokens_as_encrypted_value.php @@ -0,0 +1,84 @@ +dropUnique(['daemonSecret']); + }); + + Schema::table('nodes', function (Blueprint $table) { + $table->char('uuid', 36)->after('id')->unique(); + $table->char('daemon_token_id', 16)->after('upload_size')->unique(); + $table->renameColumn('daemonSecret', 'daemon_token'); + }); + + Schema::table('nodes', function (Blueprint $table) { + $table->text('daemon_token')->change(); + }); + + DB::transaction(function () { + /** @var \Illuminate\Contracts\Encryption\Encrypter $encrypter */ + $encrypter = Container::getInstance()->make(Encrypter::class); + + foreach (DB::select('SELECT id, daemon_token FROM nodes') as $datum) { + DB::update('UPDATE nodes SET uuid = ?, daemon_token_id = ?, daemon_token = ? WHERE id = ?', [ + Uuid::uuid4()->toString(), + substr($datum->daemon_token, 0, 16), + $encrypter->encrypt(substr($datum->daemon_token, 16)), + $datum->id, + ]); + } + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::transaction(function () { + /** @var \Illuminate\Contracts\Encryption\Encrypter $encrypter */ + $encrypter = Container::getInstance()->make(Encrypter::class); + + foreach (DB::select('SELECT id, daemon_token_id, daemon_token FROM nodes') as $datum) { + DB::update('UPDATE nodes SET daemon_token = ? WHERE id = ?', [ + $datum->daemon_token_id . $encrypter->decrypt($datum->daemon_token), + $datum->id, + ]); + } + }); + + Schema::table('nodes', function (Blueprint $table) { + $table->dropUnique(['uuid']); + $table->dropUnique(['daemon_token_id']); + }); + + Schema::table('nodes', function (Blueprint $table) { + $table->dropColumn(['uuid', 'daemon_token_id']); + $table->renameColumn('daemon_token', 'daemonSecret'); + }); + + Schema::table('nodes', function (Blueprint $table) { + $table->string('daemonSecret', 36)->change(); + $table->unique(['daemonSecret']); + }); + } +} diff --git a/resources/views/admin/nodes/index.blade.php b/resources/views/admin/nodes/index.blade.php index 6805e9ef4..b966d92cf 100644 --- a/resources/views/admin/nodes/index.blade.php +++ b/resources/views/admin/nodes/index.blade.php @@ -55,7 +55,7 @@ @foreach ($nodes as $node) - + {!! $node->maintenance_mode ? ' ' : '' !!}{{ $node->name }} {{ $node->location->short }} {{ $node->memory }} MB diff --git a/routes/daemon.php b/routes/daemon.php deleted file mode 100644 index 2c8058e36..000000000 --- a/routes/daemon.php +++ /dev/null @@ -1,13 +0,0 @@ -. - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ -Route::get('/packs/pull/{uuid}', 'PackController@pull')->name('daemon.pack.pull'); -Route::get('/packs/pull/{uuid}/hash', 'PackController@hash')->name('daemon.pack.hash'); -Route::get('/configure/{token}', 'ActionController@configuration')->name('daemon.configuration'); - -Route::post('/install', 'ActionController@markInstall')->name('daemon.install');