diff --git a/client/src/App.jsx b/client/src/App.jsx index 3d528b3..6d22200 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -13,9 +13,11 @@ import { setSnackit } from './api/wrap'; const App = () => { const [open, setOpen] = React.useState(false); const [message, setMessage] = React.useState(''); - setSnackit((message) => { + const [severity, setSeverity] = React.useState('error'); + setSnackit((message, severity='error') => { setMessage(message); setOpen(true); + setSeverity(severity); }) return ( @@ -25,7 +27,7 @@ const App = () => { onClose={() => {setOpen(false)}} anchorOrigin={{ vertical: 'top', horizontal: 'center' }} > - + {message} diff --git a/client/src/api/config.jsx b/client/src/api/config.jsx deleted file mode 100644 index 3df6992..0000000 --- a/client/src/api/config.jsx +++ /dev/null @@ -1,35 +0,0 @@ -import wrap from './wrap'; - -function get() { - return wrap(fetch('/cosmos/api/config', { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - }, - })) -} - -function set(values) { - return wrap(fetch('/cosmos/api/config', { - method: 'PUT', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(values), - })) -} - -function restart() { - return wrap(fetch('/cosmos/api/restart', { - method: 'GET', - headers: { - 'Content-Type': 'application/json' - }, - })) -} - -export { - get, - set, - restart -}; \ No newline at end of file diff --git a/client/src/api/config.ts b/client/src/api/config.ts new file mode 100644 index 0000000..178f5c4 --- /dev/null +++ b/client/src/api/config.ts @@ -0,0 +1,80 @@ +import wrap from './wrap'; +interface Route { + Name: string; +} + +type Operation = 'replace' | 'move_up' | 'move_down' | 'delete'; + +function get() { + return wrap(fetch('/cosmos/api/config', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + })) +} + +function set(values) { + return wrap(fetch('/cosmos/api/config', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(values), + })) +} + +function restart() { + return fetch('/cosmos/api/restart', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + }, + }) +} + +async function rawUpdateRoute(routeName: string, operation: Operation, newRoute?: Route): Promise { + const payload = { + routeName, + operation, + newRoute, + }; + + if (operation === 'replace') { + if (!newRoute) throw new Error('newRoute must be provided for replace operation'); + } + + return wrap(fetch('/cosmos/api/config', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + })); +} + +async function replaceRoute(routeName: string, newRoute: Route): Promise { + return rawUpdateRoute(routeName, 'replace', newRoute); +} + +async function moveRouteUp(routeName: string): Promise { + return rawUpdateRoute(routeName, 'move_up'); +} + +async function moveRouteDown(routeName: string): Promise { + return rawUpdateRoute(routeName, 'move_down'); +} + +async function deleteRoute(routeName: string): Promise { + return rawUpdateRoute(routeName, 'delete'); +} +export { + get, + set, + restart, + rawUpdateRoute, + replaceRoute, + moveRouteUp, + moveRouteDown, + deleteRoute, +}; \ No newline at end of file diff --git a/client/src/api/index.jsx b/client/src/api/index.jsx index c91a787..9a26b66 100644 --- a/client/src/api/index.jsx +++ b/client/src/api/index.jsx @@ -1,7 +1,7 @@ -import * as auth from './authentication.jsx'; -import * as users from './users.jsx'; -import * as config from './config.jsx'; -import * as docker from './docker.jsx'; +import * as auth from './authentication'; +import * as users from './users'; +import * as config from './config'; +import * as docker from './docker'; import wrap from './wrap'; @@ -14,6 +14,28 @@ const getStatus = () => { })) } +const isOnline = () => { + return fetch('/cosmos/api/status', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }).then(async (response) => { + let rep; + try { + rep = await response.json(); + } catch { + throw new Error('Server error'); + } + if (response.status == 200) { + return rep; + } + const e = new Error(rep.message); + e.status = response.status; + throw e; + }); +} + const newInstall = (req) => { return wrap(fetch('/cosmos/api/newInstall', { method: 'POST', @@ -31,4 +53,5 @@ export { docker, getStatus, newInstall, + isOnline }; \ No newline at end of file diff --git a/client/src/api/wrap.js b/client/src/api/wrap.js index 0d71f7f..e55e5a2 100644 --- a/client/src/api/wrap.js +++ b/client/src/api/wrap.js @@ -21,4 +21,8 @@ export default function wrap(apicall) { export function setSnackit(snack) { snackit = snack; -} \ No newline at end of file +} + +export { + snackit +}; \ No newline at end of file diff --git a/client/src/components/back.jsx b/client/src/components/back.jsx new file mode 100644 index 0000000..d879678 --- /dev/null +++ b/client/src/components/back.jsx @@ -0,0 +1,16 @@ +import { LeftOutlined } from "@ant-design/icons"; +import { IconButton } from "@mui/material"; +import { useNavigate } from "react-router"; + +function Back() { + const navigate = useNavigate(); + const goBack = () => { + navigate(-1); + } + return + + +; +} + +export default Back; \ No newline at end of file diff --git a/client/src/components/hostChip.jsx b/client/src/components/hostChip.jsx index 4a1df6e..4f60702 100644 --- a/client/src/components/hostChip.jsx +++ b/client/src/components/hostChip.jsx @@ -24,7 +24,10 @@ const HostChip = ({route, settings}) => { return { if(route.UseHost) window.open(window.location.origin.split("://")[0] + "://" + route.Host + route.PathPrefix, '_blank'); diff --git a/client/src/components/routeComponents.jsx b/client/src/components/routeComponents.jsx index 0e13fbf..f686bc0 100644 --- a/client/src/components/routeComponents.jsx +++ b/client/src/components/routeComponents.jsx @@ -118,13 +118,13 @@ export const RouteActions = ({route, routeKey, up, down, deleteRoute}) => { return <> {!confirmDelete && (} onClick={() => setConfirmDelete(true)}/>)} - {confirmDelete && (} color="error" onClick={() => deleteRoute()}/>)} + {confirmDelete && (} color="error" onClick={(event) => deleteRoute(event)}/>)} - up()}> + up(event)}> {routeKey} - down()}> + down(event)}> diff --git a/client/src/components/tabbedView/tabbedView.jsx b/client/src/components/tabbedView/tabbedView.jsx new file mode 100644 index 0000000..a43727a --- /dev/null +++ b/client/src/components/tabbedView/tabbedView.jsx @@ -0,0 +1,83 @@ +import React, { useState } from 'react'; +import { Box, Tab, Tabs, Typography, MenuItem, Select, useMediaQuery } from '@mui/material'; +import { styled } from '@mui/system'; + +const StyledTabs = styled(Tabs)` + border-right: 1px solid ${({ theme }) => theme.palette.divider}; +`; + +const TabPanel = (props) => { + const { children, value, index, ...other } = props; + + return ( + + ); +}; + +const a11yProps = (index) => { + return { + id: `vertical-tab-${index}`, + 'aria-controls': `vertical-tabpanel-${index}`, + }; +}; + +const PrettyTabbedView = ({ tabs }) => { + const [value, setValue] = useState(0); + const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm')); + + const handleChange = (event, newValue) => { + setValue(newValue); + }; + + const handleSelectChange = (event) => { + setValue(event.target.value); + }; + + return ( + + {isMobile ? ( + + ) : ( + + {tabs.map((tab, index) => ( + + ))} + + )} + {tabs.map((tab, index) => ( + + {tab.children} + + ))} + + ); +}; + +export default PrettyTabbedView; \ No newline at end of file diff --git a/client/src/components/tableView/prettyTableView.jsx b/client/src/components/tableView/prettyTableView.jsx index b9924f0..2eacd0c 100644 --- a/client/src/components/tableView/prettyTableView.jsx +++ b/client/src/components/tableView/prettyTableView.jsx @@ -9,8 +9,9 @@ import Paper from '@mui/material/Paper'; import { Input, InputAdornment, Stack, TextField } from '@mui/material'; import { SearchOutlined } from '@ant-design/icons'; import { useTheme } from '@mui/material/styles'; +import { Link } from 'react-router-dom'; -const PrettyTableView = ({ getKey, data, columns, onRowClick }) => { +const PrettyTableView = ({ getKey, data, columns, onRowClick, linkTo }) => { const [search, setSearch] = React.useState(''); const theme = useTheme(); const isDark = theme.palette.mode === 'dark'; @@ -59,18 +60,28 @@ const PrettyTableView = ({ getKey, data, columns, onRowClick }) => { key={getKey(row)} sx={{ cursor: 'pointer', - borderLeft: 'transparent solid 5px', + borderLeft: 'transparent solid 2px', '&:last-child td, &:last-child th': { border: 0 }, '&:hover': { - backgroundColor: 'rgba(0, 0, 0, 0.04)', + backgroundColor: 'rgba(0, 0, 0, 0.06)', borderColor: 'gray', + textDecoration: 'underline', }, }} > {columns.map((column) => ( - {column.field(row, key)} + + + {!column.clickable ? + {column.field(row, key)} + : column.field(row, key)} + ))} ))} diff --git a/client/src/index.css b/client/src/index.css index 4c66f7d..0137bc3 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -25,6 +25,16 @@ overflow: hidden; } +.stickyButton { + position: fixed; + bottom: 20px; + width: 100%; + left: 0; + right: 0; + box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.50); + z-index: 10; +} + .shinyButton:before { position: absolute; content: ''; @@ -44,4 +54,4 @@ .code { background-color: rgba(0.2,0.2,0.2,0.2); -} \ No newline at end of file +} diff --git a/client/src/layout/MainLayout/index.jsx b/client/src/layout/MainLayout/index.jsx index 37a00b7..b45e8e5 100644 --- a/client/src/layout/MainLayout/index.jsx +++ b/client/src/layout/MainLayout/index.jsx @@ -50,7 +50,7 @@ const MainLayout = () => { - + diff --git a/client/src/pages/config/routeConfig.jsx b/client/src/pages/config/routeConfig.jsx index ac6f5ea..79595fd 100644 --- a/client/src/pages/config/routeConfig.jsx +++ b/client/src/pages/config/routeConfig.jsx @@ -1,194 +1,71 @@ -import * as React from 'react'; -import MainCard from '../../../components/MainCard'; -import { Formik } from 'formik'; -import * as Yup from 'yup'; -import { - Alert, - Grid, - FormHelperText, - Chip, +import { useParams } from "react-router"; +import Back from "../../components/back"; +import { CircularProgress, Stack } from "@mui/material"; +import PrettyTabbedView from "../../components/tabbedView/tabbedView"; +import RouteManagement from "./routes/routeman"; +import { useEffect, useState } from "react"; +import * as API from "../../api"; +import RouteSecurity from "./routes/routeSecurity"; +import RouteOverview from "./routes/routeoverview"; -} from '@mui/material'; -import { CosmosCheckbox, CosmosCollapse, CosmosFormDivider, CosmosInputText, CosmosSelect } from './formShortcuts'; -import { DownOutlined, UpOutlined, CheckOutlined, DeleteOutlined } from '@ant-design/icons'; -import { CosmosContainerPicker } from './containerPicker'; -import { ValidateRoute } from './users/routeman'; +const RouteConfigPage = () => { + const { routeName } = useParams(); + const [config, setConfig] = useState(null); + + let currentRoute = null; + if (config) { + currentRoute = config.HTTPConfig.ProxyConfig.Routes.find((r) => r.Name === routeName); + } -const RouteConfig = ({route, key, lockTarget, TargetContainer, setRouteConfig}) => { - return (
- {route && <> - { - return false; - }} - // validate={(values) => { - // setRouteConfig(values); - // }} - > - {(formik) => ( -
- - - {formik.errors.submit && ( - - {formik.errors.submit} - - )} + const refreshConfig = () => { + API.config.get().then((res) => { + setConfig(res.data); + }); + }; - + useEffect(() => { + refreshConfig(); + }, []); - + return
+

+ + + +
{routeName}
+
- - - - - What are you trying to access with this route? - + {config && + }, + { + title: 'Setup', + children: + }, + { + title: 'Security', + children: + }, + { + title: 'Permissions', + children:
WIP
+ }, + ]}/>} - - - - - { - (formik.values.Mode === "SERVAPP")? - { - setRouteConfig(formik.values); - }} - /> - : - } - - - - What URL do you want to access your target from? - - - - {formik.values.UseHost && } - - - - {formik.values.UsePathPrefix && } - - {formik.values.UsePathPrefix && } - - - - - Additional security settings. MFA and Captcha are not yet implemented. - - - - - - - - - -
-
- - - - )} - - - } -

) + {!config &&
+ +
} + + +
} -export default RouteConfig; \ No newline at end of file +export default RouteConfigPage; \ No newline at end of file diff --git a/client/src/pages/config/routes/routeSecurity.jsx b/client/src/pages/config/routes/routeSecurity.jsx new file mode 100644 index 0000000..112209e --- /dev/null +++ b/client/src/pages/config/routes/routeSecurity.jsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import * as API from '../../../api'; +import MainCard from '../../../components/MainCard'; +import { Formik } from 'formik'; +import { + Alert, + Button, + Grid, + Stack, + +} from '@mui/material'; +import RestartModal from '../users/restart'; +import { CosmosCheckbox, CosmosInputText } from '../users/formShortcuts'; +import { snackit } from '../../../api/wrap'; + +const RouteSecurity = ({ routeConfig }) => { + const [openModal, setOpenModal] = React.useState(false); + + return
+ + + {routeConfig && <> + { + const fullValues = { + ...routeConfig, + ...values, + } + API.config.replaceRoute(routeConfig.Name, fullValues).then((res) => { + if (res.status == "OK") { + setStatus({ success: true }); + snackit('Route updated successfully', 'success'); + setSubmitting(false); + setOpenModal(true); + } else { + setStatus({ success: false }); + setErrors({ submit: res.status }); + setSubmitting(false); + } + }); + }} + > + {(formik) => ( +
+ + + + + Additional security settings. MFA and Captcha are not yet implemented. + + + + + + + + + + + + + + + +
+ )} +
+ } +
; +} + +export default RouteSecurity; \ No newline at end of file diff --git a/client/src/pages/config/routes/routeman.jsx b/client/src/pages/config/routes/routeman.jsx new file mode 100644 index 0000000..3bc7b88 --- /dev/null +++ b/client/src/pages/config/routes/routeman.jsx @@ -0,0 +1,221 @@ +import * as React from 'react'; +import * as API from '../../../api'; +import MainCard from '../../../components/MainCard'; +import { Formik } from 'formik'; +import * as Yup from 'yup'; +import { + Alert, + Button, + Grid, + Stack, + FormHelperText, +} from '@mui/material'; +import RestartModal from '../users/restart'; +import { CosmosCheckbox, CosmosFormDivider, CosmosInputText, CosmosSelect } from '../users/formShortcuts'; +import { CosmosContainerPicker } from '../users/containerPicker'; +import { snackit } from '../../../api/wrap'; + +export const ValidateRoute = Yup.object().shape({ + Name: Yup.string().required('Name is required'), + Mode: Yup.string().required('Mode is required'), + Target: Yup.string().required('Target is required').when('Mode', { + is: 'SERVAPP', + then: Yup.string().matches(/:[0-9]+$/, 'Invalid Target, must have a port'), + }), + + Host: Yup.string().when('UseHost', { + is: true, + then: Yup.string().required('Host is required') + .matches(/[\.|\:]/, 'Host must be full domain ([sub.]domain.com) or an IP') + }), + + PathPrefix: Yup.string().when('UsePathPrefix', { + is: true, + then: Yup.string().required('Path Prefix is required').matches(/^\//, 'Path Prefix must start with / (e.g. /api). Do not include a domain/subdomain in it, use the Host for this.') + }), + + UseHost: Yup.boolean().when('UsePathPrefix', + { + is: false, + then: Yup.boolean().oneOf([true], 'Source must at least be either Host or Path Prefix') + }), +}) + +const Hide = ({ children, h }) => { + return h ?
+ {children} +
: <>{children} +} + +const RouteManagement = ({ routeConfig, TargetContainer, noControls = false, lockTarget = false, title, setRouteConfig, submitButton = false }) => { + const [openModal, setOpenModal] = React.useState(false); + + return
+ + + {routeConfig && <> + { + if(!submitButton) { + return false; + } else { + const fullValues = { + ...routeConfig, + ...values, + } + API.config.replaceRoute(routeConfig.Name, fullValues).then((res) => { + if (res.status == "OK") { + setStatus({ success: true }); + snackit('Route updated successfully', 'success') + setSubmitting(false); + setOpenModal(true); + } else { + setStatus({ success: false }); + setErrors({ submit: res.status }); + setSubmitting(false); + } + }); + } + }} + validate={(values) => { + setRouteConfig && setRouteConfig(values); + }} + > + {(formik) => ( +
+ + {title || routeConfig.Name}
+ }> + + {formik.errors.submit && ( + + {formik.errors.submit} + + )} + + + + + + + + + What are you trying to access with this route? + + + + + + + { + (formik.values.Mode === "SERVAPP") ? + { + setRouteConfig && setRouteConfig(formik.values); + }} + /> + : + } + + + + + What URL do you want to access your target from? + + + + + {formik.values.UseHost && } + + + + {formik.values.UsePathPrefix && } + + {formik.values.UsePathPrefix && } + + + {submitButton && } + + + )} + + } + ; +} + +export default RouteManagement; \ No newline at end of file diff --git a/client/src/pages/config/routes/routeoverview.jsx b/client/src/pages/config/routes/routeoverview.jsx new file mode 100644 index 0000000..f659c53 --- /dev/null +++ b/client/src/pages/config/routes/routeoverview.jsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import MainCard from '../../../components/MainCard'; +import RestartModal from '../users/restart'; +import { Chip, Stack, useMediaQuery } from '@mui/material'; +import HostChip from '../../../components/HostChip'; +import { RouteMode, RouteSecurity } from '../../../components/routeComponents'; +import { getFaviconURL } from '../../../utils/routes'; + +const RouteOverview = ({ routeConfig }) => { + const [openModal, setOpenModal] = React.useState(false); + const isMobile = useMediaQuery((theme) => theme.breakpoints.down('sm')); + + return
+ + + {routeConfig && <> + + +
+ +
+ + Description +
{routeConfig.Description}
+ URL +
+ Target +
+ Security +
+
+
+
+ } +
; +} + +export default RouteOverview; \ No newline at end of file diff --git a/client/src/pages/config/users/proxyman.jsx b/client/src/pages/config/users/proxyman.jsx index 6e85ffd..e03b743 100644 --- a/client/src/pages/config/users/proxyman.jsx +++ b/client/src/pages/config/users/proxyman.jsx @@ -25,17 +25,19 @@ import { TextField, MenuItem, Chip, + CircularProgress, } from '@mui/material'; import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; import AnimateButton from '../../../components/@extended/AnimateButton'; import RestartModal from './restart'; -import RouteManagement, {ValidateRoute} from './routeman'; +import RouteManagement, {ValidateRoute} from '../routes/routeman'; import { map } from 'lodash'; import { getFaviconURL, sanitizeRoute } from '../../../utils/routes'; import PrettyTableView from '../../../components/tableView/prettyTableView'; import HostChip from '../../../components/hostChip'; import {RouteActions, RouteMode, RouteSecurity} from '../../../components/routeComponents'; +import { useNavigate } from 'react-router'; const stickyButton = { position: 'fixed', @@ -60,6 +62,7 @@ const ProxyManagement = () => { const [error, setError] = React.useState(null); const [submitErrors, setSubmitErrors] = React.useState([]); const [needSave, setNeedSave] = React.useState(false); + const navigate = useNavigate(); function updateRoutes(routes) { let con = { @@ -90,7 +93,8 @@ const ProxyManagement = () => { }); } - function up(key) { + function up(event, key) { + event.stopPropagation(); if (key > 0) { let tmp = routes[key]; routes[key] = routes[key-1]; @@ -98,15 +102,19 @@ const ProxyManagement = () => { updateRoutes(routes); setNeedSave(true); } + return false; } - function deleteRoute(key) { + function deleteRoute(event, key) { + event.stopPropagation(); routes.splice(key, 1); updateRoutes(routes); setNeedSave(true); + return false; } - function down(key) { + function down(event, key) { + event.stopPropagation(); if (key < routes.length - 1) { let tmp = routes[key]; routes[key] = routes[key+1]; @@ -114,6 +122,7 @@ const ProxyManagement = () => { updateRoutes(routes); setNeedSave(true); } + return false; } React.useEffect(() => { @@ -161,6 +170,7 @@ const ProxyManagement = () => { {routes && r.Name + r.Target + r.Mode} + onRowClick={(r) => {navigate('/ui/config-url/' + r.Name)}} columns={[ { title: '', @@ -171,9 +181,12 @@ const ProxyManagement = () => { }, { title: 'URL', search: (r) => r.Name + ' ' + r.Description, + style: { + textDecoration: 'inherit', + }, field: (r) => <> -
{r.Name}

-
{r.Description}
+
{r.Name}

+
{r.Description}
}, // { title: 'Description', field: (r) => shorten(r.Description), style:{fontSize: '90%', opacity: '90%'} }, @@ -182,12 +195,12 @@ const ProxyManagement = () => { { title: 'Target', search: (r) => r.Target, field: (r) => <> }, { title: 'Security', field: (r) => , style: {minWidth: '70px'} }, - { title: '', field: (r, k) => up(k)} - down={() => down(k)} - deleteRoute={() => deleteRoute(k)} + up={(event) => up(event, k)} + down={(event) => down(event, k)} + deleteRoute={(event) => deleteRoute(event, k)} />, style: { textAlign: 'right', @@ -195,6 +208,11 @@ const ProxyManagement = () => { }, ]} />} + { + !routes &&
+ +
+ } {/* {routes && routes.map((route,key) => (<> { + window.location.reload(); + }).catch((err) => { + setTimeout(() => { + checkIsOnline(); + }, 1000); + }); +} + const RestartModal = ({openModal, setOpenModal}) => { + const [isRestarting, setIsRestarting] = useState(false); + const [warn, setWarn] = useState(false); + return <> setOpenModal(false)}> - Restart Server + {!isRestarting ? 'Restart Server?' : 'Restarting Server...'} - A restart is required to apply changes. Do you want to restart? + {warn &&
+ }> + The server is taking longer than expected to restart.
Consider troubleshouting the logs. +
+
} + {isRestarting ? +
+ +
+ : 'A restart is required to apply changes. Do you want to restart?'}
- + {!isRestarting && - + }
; }; diff --git a/client/src/pages/config/users/routeman.jsx b/client/src/pages/config/users/routeman.jsx deleted file mode 100644 index 046142f..0000000 --- a/client/src/pages/config/users/routeman.jsx +++ /dev/null @@ -1,257 +0,0 @@ -import * as React from 'react'; -import IsLoggedIn from '../../../IsLoggedIn'; -import * as API from '../../../api'; -import MainCard from '../../../components/MainCard'; -import { Formik, Field } from 'formik'; -import * as Yup from 'yup'; -import { - Alert, - Button, - Checkbox, - Divider, - FormControlLabel, - Grid, - IconButton, - InputAdornment, - InputLabel, - Link, - OutlinedInput, - Stack, - Typography, - FormHelperText, - Collapse, - TextField, - MenuItem, - Card, - Chip, - -} from '@mui/material'; -import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons'; -import AnimateButton from '../../../components/@extended/AnimateButton'; -import RestartModal from './restart'; -import { CosmosCheckbox, CosmosCollapse, CosmosFormDivider, CosmosInputText, CosmosSelect } from './formShortcuts'; -import { DownOutlined, UpOutlined, CheckOutlined, DeleteOutlined } from '@ant-design/icons'; -import { CosmosContainerPicker } from './containerPicker'; - -export const ValidateRoute = Yup.object().shape({ - Name: Yup.string().required('Name is required'), - Mode: Yup.string().required('Mode is required'), - Target: Yup.string().required('Target is required').when('Mode', { - is: 'SERVAPP', - then: Yup.string().matches(/:[0-9]+$/, 'Invalid Target, must have a port'), - }), - - Host: Yup.string().when('UseHost', { - is: true, - then: Yup.string().required('Host is required') - .matches(/[\.|\:]/, 'Host must be full domain ([sub.]domain.com) or an IP') - }), - - PathPrefix: Yup.string().when('UsePathPrefix', { - is: true, - then: Yup.string().required('Path Prefix is required').matches(/^\//, 'Path Prefix must start with / (e.g. /api). Do not include a domain/subdomain in it, use the Host for this.') - }), - - UseHost: Yup.boolean().when('UsePathPrefix', - { - is: false, - then: Yup.boolean().oneOf([true], 'Source must at least be either Host or Path Prefix') - }), -}) - -const RouteManagement = ({ routeConfig, TargetContainer, noControls=false, lockTarget=false, setRouteConfig, up, down, deleteRoute }) => { - const [confirmDelete, setConfirmDelete] = React.useState(false); - const myRef = React.useRef(null) - const currRef = myRef.current; - - React.useEffect(() => { - if(currRef && window.location.hash === '#' + routeConfig.Name) { - currRef.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [currRef]) - - return
- {routeConfig && <> - { - return false; - }} - validate={(values) => { - setRouteConfig(values); - }} - > - {(formik) => ( -
- {routeConfig.Name}   - } onClick={() => up()}/>   - } onClick={() => down()}/>   - {!confirmDelete && (} onClick={() => setConfirmDelete(true)}/>)} - {confirmDelete && (} onClick={() => deleteRoute()}/>)}   -
- }> - - {formik.errors.submit && ( - - {formik.errors.submit} - - )} - - - - - - - - - - What are you trying to access with this route? - - - - - - - { - (formik.values.Mode === "SERVAPP")? - { - setRouteConfig(formik.values); - }} - /> - : - } - - - - What URL do you want to access your target from? - - - - {formik.values.UseHost && } - - - - {formik.values.UsePathPrefix && } - - {formik.values.UsePathPrefix && } - - - - - Additional security settings. MFA and Captcha are not yet implemented. - - - - - - - - - - - - - - - )} - - } - ; -} - -export default RouteManagement; \ No newline at end of file diff --git a/client/src/pages/servapps/servapps.jsx b/client/src/pages/servapps/servapps.jsx index d4e9fd3..069571e 100644 --- a/client/src/pages/servapps/servapps.jsx +++ b/client/src/pages/servapps/servapps.jsx @@ -10,8 +10,8 @@ import { styled } from '@mui/material/styles'; import * as API from '../../api'; import IsLoggedIn from '../../IsLoggedIn'; import RestartModal from '../config/users/restart'; -import RouteManagement, { ValidateRoute } from '../config/users/routeman'; -import { sanitizeRoute } from '../../utils/routes'; +import RouteManagement, { ValidateRoute } from '../config/routes/routeman'; +import { getFaviconURL, sanitizeRoute } from '../../utils/routes'; import HostChip from '../../components/hostChip'; const Item = styled(Paper)(({ theme }) => ({ @@ -123,6 +123,17 @@ const ServeApps = () => { return name.replace('/', '').replace(/_/g, '-').replace(/[^a-zA-Z0-9-]/g, '').toLowerCase().replace(/\s/g, '-') + '.' + window.location.origin.split('://')[1] } + const getFirstRouteFavIcon = (app) => { + let routes = getContainersRoutes(app.Names[0].replace('/', '')); + console.log(routes) + if(routes.length > 0) { + let url = getFaviconURL(routes[0]); + return url; + } else { + return getFaviconURL(''); + } + } + return
@@ -235,13 +246,16 @@ const ServeApps = () => { })[app.State] } - - - {app.Names[0].replace('/', '')}  - - - {app.Image} - + + + + + {app.Names[0].replace('/', '')}  + + + {app.Image} + + diff --git a/client/src/routes/MainRoutes.jsx b/client/src/routes/MainRoutes.jsx index 049a533..5509e39 100644 --- a/client/src/routes/MainRoutes.jsx +++ b/client/src/routes/MainRoutes.jsx @@ -8,6 +8,7 @@ import ConfigManagement from '../pages/config/users/configman'; import ProxyManagement from '../pages/config/users/proxyman'; import ServeApps from '../pages/servapps/servapps'; import { Navigate } from 'react-router'; +import RouteConfigPage from '../pages/config/RouteConfig'; // render - dashboard const DashboardDefault = Loadable(lazy(() => import('../pages/dashboard'))); @@ -52,6 +53,10 @@ const MainRoutes = { path: '/ui/config-url', element: }, + { + path: '/ui/config-url/:routeName', + element: , + }, ] }; diff --git a/go.mod b/go.mod index 2e5d2a4..957ce73 100644 --- a/go.mod +++ b/go.mod @@ -35,6 +35,7 @@ require ( github.com/docker/docker v23.0.1+incompatible // indirect github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/evanphx/json-patch v0.5.2 // indirect github.com/exoscale/egoscale v0.40.0 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/foomo/simplecert v1.8.4 // indirect diff --git a/go.sum b/go.sum index 51deb9e..e8e2b5a 100644 --- a/go.sum +++ b/go.sum @@ -154,6 +154,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= github.com/exoscale/egoscale v0.23.0/go.mod h1:hRo78jkjkCDKpivQdRBEpNYF5+cVpCJCPDg2/r45KaY= github.com/exoscale/egoscale v0.40.0 h1:fvVKszvqAXNP1ryhC0rwsKHPenyMaV0fGf14oUMNZFw= github.com/exoscale/egoscale v0.40.0/go.mod h1:BFi2GNsnsrALev3+gFO/HIQADBQhqJ41S0QrNEB2GJw= @@ -189,6 +191,7 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2 github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/errors v0.19.2/go.mod h1:qX0BLWsyaKfvhluLejVpVNwNRdXZhEbTA4kxxpKBC94= github.com/go-openapi/strfmt v0.19.8/go.mod h1:qBBipho+3EoIqn6YDI+4RnQEtj6jT/IdKm+PAlXxSUc= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -343,6 +346,7 @@ github.com/jarcoal/httpmock v1.0.7/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT github.com/jasonlvhit/gocron v0.0.1 h1:qTt5qF3b3srDjeOIR4Le1LfeyvoYzJlYpqvG7tJX5YU= github.com/jasonlvhit/gocron v0.0.1/go.mod h1:k9a3TV8VcU73XZxfVHCHWMWF9SOqgoku0/QlY2yvlA4= github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= @@ -391,6 +395,7 @@ github.com/linode/linodego v0.24.2 h1:wj7DO4/8zGEJm6HtGp03L1HLATzmffkwEoifiKIFi0 github.com/linode/linodego v0.24.2/go.mod h1:GSBKPpjoQfxEfryoCRcgkuUOCuVtGHWhzI8OMdycNTE= github.com/liquidweb/liquidweb-go v1.6.1 h1:O51RbJo3ZEWFkZFfP32zIF6MCoZzwuuybuXsvZvVEEI= github.com/liquidweb/liquidweb-go v1.6.1/go.mod h1:UDcVnAMDkZxpw4Y7NOHkqoeiGacVLEIG/i5J9cyixzQ= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= @@ -567,6 +572,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.2/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.deanishe.net/favicon v0.1.0 h1:Afy941gjRik+DjUUcYHUxcztFEeFse2ITBkMMOlgefM= go.deanishe.net/favicon v0.1.0/go.mod h1:vIKVI+lUh8k3UAzaN4gjC+cpyatLQWmx0hVX4vLE8jU= go.mongodb.org/mongo-driver v1.4.2/go.mod h1:WcMNYLx/IlOxLe6JRJiv2uXuCz6zBLndR4SoGjYphSc= diff --git a/src/configapi/patch.go b/src/configapi/patch.go new file mode 100644 index 0000000..d537612 --- /dev/null +++ b/src/configapi/patch.go @@ -0,0 +1,83 @@ +package configapi + +import ( + "encoding/json" + "net/http" + "sync" + + "github.com/azukaar/cosmos-server/src/utils" +) + +type UpdateRouteRequest struct { + RouteName string `json:"routeName"` + Operation string `json:"operation"` + NewRoute *utils.ProxyRouteConfig `json:"newRoute,omitempty"` +} + +var configLock sync.Mutex + +func ConfigApiPatch(w http.ResponseWriter, req *http.Request) { + if utils.AdminOnly(w, req) != nil { + return + } + + configLock.Lock() + defer configLock.Unlock() + + var updateReq UpdateRouteRequest + err := json.NewDecoder(req.Body).Decode(&updateReq) + if err != nil { + utils.Error("SettingsUpdate: Invalid Update Request", err) + utils.HTTPError(w, "Invalid Update Request", http.StatusBadRequest, "UR001") + return + } + + config := utils.ReadConfigFromFile() + routes := config.HTTPConfig.ProxyConfig.Routes + routeIndex := -1 + + for i, route := range routes { + if route.Name == updateReq.RouteName { + routeIndex = i + break + } + } + + if routeIndex == -1 { + utils.Error("SettingsUpdate: Route not found: "+updateReq.RouteName, nil) + utils.HTTPError(w, "Route not found", http.StatusNotFound, "UR002") + return + } + + switch updateReq.Operation { + case "replace": + if updateReq.NewRoute == nil { + utils.Error("SettingsUpdate: NewRoute must be provided for replace operation", nil) + utils.HTTPError(w, "NewRoute must be provided for replace operation", http.StatusBadRequest, "UR003") + return + } + routes[routeIndex] = *updateReq.NewRoute + case "move_up": + if routeIndex > 0 { + routes[routeIndex-1], routes[routeIndex] = routes[routeIndex], routes[routeIndex-1] + } + case "move_down": + if routeIndex < len(routes)-1 { + routes[routeIndex+1], routes[routeIndex] = routes[routeIndex], routes[routeIndex+1] + } + case "delete": + routes = append(routes[:routeIndex], routes[routeIndex+1:]...) + default: + utils.Error("SettingsUpdate: Unsupported operation: "+updateReq.Operation, nil) + utils.HTTPError(w, "Unsupported operation", http.StatusBadRequest, "UR004") + return + } + + config.HTTPConfig.ProxyConfig.Routes = routes + utils.SaveConfigTofile(config) + utils.NeedsRestart = true + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "OK", + }) +} \ No newline at end of file diff --git a/src/configapi/route.go b/src/configapi/route.go index 389ed9f..22157e1 100644 --- a/src/configapi/route.go +++ b/src/configapi/route.go @@ -10,6 +10,8 @@ func ConfigRoute(w http.ResponseWriter, req *http.Request) { ConfigApiGet(w, req) } else if (req.Method == "PUT") { ConfigApiSet(w, req) + } else if (req.Method == "PATCH") { + ConfigApiPatch(w, req) } else { utils.Error("UserRoute: Method not allowed" + req.Method, nil) utils.HTTPError(w, "Method not allowed", http.StatusMethodNotAllowed, "HTTP001") diff --git a/src/configapi/set.go b/src/configapi/set.go index 62bc501..d4b04c0 100644 --- a/src/configapi/set.go +++ b/src/configapi/set.go @@ -38,13 +38,6 @@ func ConfigApiSet(w http.ResponseWriter, req *http.Request) { utils.SaveConfigTofile(request) utils.NeedsRestart = true - // if err != nil { - // utils.Error("SettingsUpdate: Error saving config to file", err) - // utils.HTTPError(w, "Error saving config to file", - // http.StatusInternalServerError, "CS001") - // return - // } - json.NewEncoder(w).Encode(map[string]interface{}{ "status": "OK", }) diff --git a/src/httpServer.go b/src/httpServer.go index 85caf73..36764de 100644 --- a/src/httpServer.go +++ b/src/httpServer.go @@ -198,7 +198,11 @@ func StartServer() { srapi.HandleFunc("/api/servapps", docker.ContainersRoute) srapi.Use(tokenMiddleware) - srapi.Use(utils.CORSHeader(utils.GetMainConfig().HTTPConfig.Hostname)) + srapi.Use(proxy.SmartShieldMiddleware( + utils.SmartShieldPolicy{ + Enabled: true, + }, + )) srapi.Use(utils.MiddlewareTimeout(20 * time.Second)) srapi.Use(httprate.Limit(60, 1*time.Minute, httprate.WithKeyFuncs(httprate.KeyByIP),