diff --git a/.coderabbit.yaml b/client/.coderrabbit.yaml similarity index 100% rename from .coderabbit.yaml rename to client/.coderrabbit.yaml diff --git a/.dockerignore b/client/.dockerignore similarity index 100% rename from .dockerignore rename to client/.dockerignore diff --git a/.env.production b/client/.env.production similarity index 100% rename from .env.production rename to client/.env.production diff --git a/.eslintrc.cjs b/client/.eslintrc.cjs similarity index 100% rename from .eslintrc.cjs rename to client/.eslintrc.cjs diff --git a/.gitignore b/client/.gitignore similarity index 100% rename from .gitignore rename to client/.gitignore diff --git a/.prettierrc b/client/.prettierrc similarity index 100% rename from .prettierrc rename to client/.prettierrc diff --git a/CODE_OF_CONDUCT.md b/client/CODE_OF_CONDUCT.md similarity index 100% rename from CODE_OF_CONDUCT.md rename to client/CODE_OF_CONDUCT.md diff --git a/CONTRIBUTING.md b/client/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to client/CONTRIBUTING.md diff --git a/LICENSE b/client/LICENSE similarity index 100% rename from LICENSE rename to client/LICENSE diff --git a/PULLREQUESTS.md b/client/PULLREQUESTS.md similarity index 100% rename from PULLREQUESTS.md rename to client/PULLREQUESTS.md diff --git a/README.md b/client/README.md similarity index 100% rename from README.md rename to client/README.md diff --git a/SECURITY.md b/client/SECURITY.md similarity index 100% rename from SECURITY.md rename to client/SECURITY.md diff --git a/index.html b/client/index.html similarity index 100% rename from index.html rename to client/index.html diff --git a/package-lock.json b/client/package-lock.json similarity index 100% rename from package-lock.json rename to client/package-lock.json diff --git a/package.json b/client/package.json similarity index 100% rename from package.json rename to client/package.json diff --git a/public/checkmate_favicon.svg b/client/public/checkmate_favicon.svg similarity index 100% rename from public/checkmate_favicon.svg rename to client/public/checkmate_favicon.svg diff --git a/renovate.json b/client/renovate.json similarity index 100% rename from renovate.json rename to client/renovate.json diff --git a/src/App.jsx b/client/src/App.jsx similarity index 100% rename from src/App.jsx rename to client/src/App.jsx diff --git a/src/Components/ActionsMenu/index.jsx b/client/src/Components/ActionsMenu/index.jsx similarity index 100% rename from src/Components/ActionsMenu/index.jsx rename to client/src/Components/ActionsMenu/index.jsx diff --git a/src/Components/Alert/index.css b/client/src/Components/Alert/index.css similarity index 100% rename from src/Components/Alert/index.css rename to client/src/Components/Alert/index.css diff --git a/src/Components/Alert/index.jsx b/client/src/Components/Alert/index.jsx similarity index 100% rename from src/Components/Alert/index.jsx rename to client/src/Components/Alert/index.jsx diff --git a/src/Components/Animated/PulseDot.jsx b/client/src/Components/Animated/PulseDot.jsx similarity index 100% rename from src/Components/Animated/PulseDot.jsx rename to client/src/Components/Animated/PulseDot.jsx diff --git a/src/Components/Avatar/index.css b/client/src/Components/Avatar/index.css similarity index 100% rename from src/Components/Avatar/index.css rename to client/src/Components/Avatar/index.css diff --git a/src/Components/Avatar/index.jsx b/client/src/Components/Avatar/index.jsx similarity index 100% rename from src/Components/Avatar/index.jsx rename to client/src/Components/Avatar/index.jsx diff --git a/src/Components/Breadcrumbs/index.css b/client/src/Components/Breadcrumbs/index.css similarity index 100% rename from src/Components/Breadcrumbs/index.css rename to client/src/Components/Breadcrumbs/index.css diff --git a/src/Components/Breadcrumbs/index.jsx b/client/src/Components/Breadcrumbs/index.jsx similarity index 100% rename from src/Components/Breadcrumbs/index.jsx rename to client/src/Components/Breadcrumbs/index.jsx diff --git a/src/Components/Buttons/RoundGradientButton.jsx b/client/src/Components/Buttons/RoundGradientButton.jsx similarity index 100% rename from src/Components/Buttons/RoundGradientButton.jsx rename to client/src/Components/Buttons/RoundGradientButton.jsx diff --git a/src/Components/Charts/AreaChart/index.jsx b/client/src/Components/Charts/AreaChart/index.jsx similarity index 100% rename from src/Components/Charts/AreaChart/index.jsx rename to client/src/Components/Charts/AreaChart/index.jsx diff --git a/src/Components/Charts/BarChart/index.css b/client/src/Components/Charts/BarChart/index.css similarity index 100% rename from src/Components/Charts/BarChart/index.css rename to client/src/Components/Charts/BarChart/index.css diff --git a/src/Components/Charts/BarChart/index.jsx b/client/src/Components/Charts/BarChart/index.jsx similarity index 100% rename from src/Components/Charts/BarChart/index.jsx rename to client/src/Components/Charts/BarChart/index.jsx diff --git a/src/Components/Charts/ChartBox/EmptyView.jsx b/client/src/Components/Charts/ChartBox/EmptyView.jsx similarity index 100% rename from src/Components/Charts/ChartBox/EmptyView.jsx rename to client/src/Components/Charts/ChartBox/EmptyView.jsx diff --git a/src/Components/Charts/ChartBox/index.jsx b/client/src/Components/Charts/ChartBox/index.jsx similarity index 100% rename from src/Components/Charts/ChartBox/index.jsx rename to client/src/Components/Charts/ChartBox/index.jsx diff --git a/src/Components/Charts/CustomGauge/index.css b/client/src/Components/Charts/CustomGauge/index.css similarity index 100% rename from src/Components/Charts/CustomGauge/index.css rename to client/src/Components/Charts/CustomGauge/index.css diff --git a/src/Components/Charts/CustomGauge/index.jsx b/client/src/Components/Charts/CustomGauge/index.jsx similarity index 100% rename from src/Components/Charts/CustomGauge/index.jsx rename to client/src/Components/Charts/CustomGauge/index.jsx diff --git a/src/Components/Charts/DePINStatusPageBarChart/index.jsx b/client/src/Components/Charts/DePINStatusPageBarChart/index.jsx similarity index 100% rename from src/Components/Charts/DePINStatusPageBarChart/index.jsx rename to client/src/Components/Charts/DePINStatusPageBarChart/index.jsx diff --git a/src/Components/Charts/LegendBox/index.jsx b/client/src/Components/Charts/LegendBox/index.jsx similarity index 100% rename from src/Components/Charts/LegendBox/index.jsx rename to client/src/Components/Charts/LegendBox/index.jsx diff --git a/src/Components/Charts/MonitorDetailsAreaChart/index.css b/client/src/Components/Charts/MonitorDetailsAreaChart/index.css similarity index 100% rename from src/Components/Charts/MonitorDetailsAreaChart/index.css rename to client/src/Components/Charts/MonitorDetailsAreaChart/index.css diff --git a/src/Components/Charts/MonitorDetailsAreaChart/index.jsx b/client/src/Components/Charts/MonitorDetailsAreaChart/index.jsx similarity index 100% rename from src/Components/Charts/MonitorDetailsAreaChart/index.jsx rename to client/src/Components/Charts/MonitorDetailsAreaChart/index.jsx diff --git a/src/Components/Charts/StatusPageBarChart/index.jsx b/client/src/Components/Charts/StatusPageBarChart/index.jsx similarity index 100% rename from src/Components/Charts/StatusPageBarChart/index.jsx rename to client/src/Components/Charts/StatusPageBarChart/index.jsx diff --git a/src/Components/Charts/Utils/chartUtilFunctions.js b/client/src/Components/Charts/Utils/chartUtilFunctions.js similarity index 100% rename from src/Components/Charts/Utils/chartUtilFunctions.js rename to client/src/Components/Charts/Utils/chartUtilFunctions.js diff --git a/src/Components/Charts/Utils/chartUtils.jsx b/client/src/Components/Charts/Utils/chartUtils.jsx similarity index 100% rename from src/Components/Charts/Utils/chartUtils.jsx rename to client/src/Components/Charts/Utils/chartUtils.jsx diff --git a/src/Components/Charts/Utils/gradientUtils.jsx b/client/src/Components/Charts/Utils/gradientUtils.jsx similarity index 100% rename from src/Components/Charts/Utils/gradientUtils.jsx rename to client/src/Components/Charts/Utils/gradientUtils.jsx diff --git a/src/Components/Check/Check.jsx b/client/src/Components/Check/Check.jsx similarity index 100% rename from src/Components/Check/Check.jsx rename to client/src/Components/Check/Check.jsx diff --git a/src/Components/Check/check.css b/client/src/Components/Check/check.css similarity index 100% rename from src/Components/Check/check.css rename to client/src/Components/Check/check.css diff --git a/src/Components/CircularCount/index.jsx b/client/src/Components/CircularCount/index.jsx similarity index 100% rename from src/Components/CircularCount/index.jsx rename to client/src/Components/CircularCount/index.jsx diff --git a/src/Components/Common/AppBar.jsx b/client/src/Components/Common/AppBar.jsx similarity index 100% rename from src/Components/Common/AppBar.jsx rename to client/src/Components/Common/AppBar.jsx diff --git a/src/Components/Common/Footer.jsx b/client/src/Components/Common/Footer.jsx similarity index 100% rename from src/Components/Common/Footer.jsx rename to client/src/Components/Common/Footer.jsx diff --git a/src/Components/ConfigBox/index.jsx b/client/src/Components/ConfigBox/index.jsx similarity index 100% rename from src/Components/ConfigBox/index.jsx rename to client/src/Components/ConfigBox/index.jsx diff --git a/src/Components/ConfigRow/index.jsx b/client/src/Components/ConfigRow/index.jsx similarity index 100% rename from src/Components/ConfigRow/index.jsx rename to client/src/Components/ConfigRow/index.jsx diff --git a/src/Components/Dialog/genericDialog.jsx b/client/src/Components/Dialog/genericDialog.jsx similarity index 100% rename from src/Components/Dialog/genericDialog.jsx rename to client/src/Components/Dialog/genericDialog.jsx diff --git a/src/Components/Dialog/index.jsx b/client/src/Components/Dialog/index.jsx similarity index 100% rename from src/Components/Dialog/index.jsx rename to client/src/Components/Dialog/index.jsx diff --git a/src/Components/Dot/index.jsx b/client/src/Components/Dot/index.jsx similarity index 100% rename from src/Components/Dot/index.jsx rename to client/src/Components/Dot/index.jsx diff --git a/src/Components/Fallback/index.css b/client/src/Components/Fallback/index.css similarity index 100% rename from src/Components/Fallback/index.css rename to client/src/Components/Fallback/index.css diff --git a/src/Components/Fallback/index.jsx b/client/src/Components/Fallback/index.jsx similarity index 100% rename from src/Components/Fallback/index.jsx rename to client/src/Components/Fallback/index.jsx diff --git a/src/Components/FilterHeader/index.jsx b/client/src/Components/FilterHeader/index.jsx similarity index 100% rename from src/Components/FilterHeader/index.jsx rename to client/src/Components/FilterHeader/index.jsx diff --git a/src/Components/GenericFallback/NetworkError.jsx b/client/src/Components/GenericFallback/NetworkError.jsx similarity index 100% rename from src/Components/GenericFallback/NetworkError.jsx rename to client/src/Components/GenericFallback/NetworkError.jsx diff --git a/src/Components/GenericFallback/index.jsx b/client/src/Components/GenericFallback/index.jsx similarity index 100% rename from src/Components/GenericFallback/index.jsx rename to client/src/Components/GenericFallback/index.jsx diff --git a/src/Components/HOC/withAdminCheck.jsx b/client/src/Components/HOC/withAdminCheck.jsx similarity index 100% rename from src/Components/HOC/withAdminCheck.jsx rename to client/src/Components/HOC/withAdminCheck.jsx diff --git a/src/Components/Heading/index.jsx b/client/src/Components/Heading/index.jsx similarity index 100% rename from src/Components/Heading/index.jsx rename to client/src/Components/Heading/index.jsx diff --git a/src/Components/Host/index.jsx b/client/src/Components/Host/index.jsx similarity index 100% rename from src/Components/Host/index.jsx rename to client/src/Components/Host/index.jsx diff --git a/src/Components/HttpStatusLabel/index.jsx b/client/src/Components/HttpStatusLabel/index.jsx similarity index 100% rename from src/Components/HttpStatusLabel/index.jsx rename to client/src/Components/HttpStatusLabel/index.jsx diff --git a/src/Components/IconBox/index.jsx b/client/src/Components/IconBox/index.jsx similarity index 100% rename from src/Components/IconBox/index.jsx rename to client/src/Components/IconBox/index.jsx diff --git a/src/Components/Image/index.jsx b/client/src/Components/Image/index.jsx similarity index 100% rename from src/Components/Image/index.jsx rename to client/src/Components/Image/index.jsx diff --git a/src/Components/InfoBox/index.jsx b/client/src/Components/InfoBox/index.jsx similarity index 100% rename from src/Components/InfoBox/index.jsx rename to client/src/Components/InfoBox/index.jsx diff --git a/src/Components/Inputs/Checkbox/index.css b/client/src/Components/Inputs/Checkbox/index.css similarity index 100% rename from src/Components/Inputs/Checkbox/index.css rename to client/src/Components/Inputs/Checkbox/index.css diff --git a/src/Components/Inputs/Checkbox/index.jsx b/client/src/Components/Inputs/Checkbox/index.jsx similarity index 100% rename from src/Components/Inputs/Checkbox/index.jsx rename to client/src/Components/Inputs/Checkbox/index.jsx diff --git a/src/Components/Inputs/ColorPicker/index.jsx b/client/src/Components/Inputs/ColorPicker/index.jsx similarity index 100% rename from src/Components/Inputs/ColorPicker/index.jsx rename to client/src/Components/Inputs/ColorPicker/index.jsx diff --git a/src/Components/Inputs/Image/index.css b/client/src/Components/Inputs/Image/index.css similarity index 100% rename from src/Components/Inputs/Image/index.css rename to client/src/Components/Inputs/Image/index.css diff --git a/src/Components/Inputs/Image/index.jsx b/client/src/Components/Inputs/Image/index.jsx similarity index 100% rename from src/Components/Inputs/Image/index.jsx rename to client/src/Components/Inputs/Image/index.jsx diff --git a/src/Components/Inputs/Radio/index.css b/client/src/Components/Inputs/Radio/index.css similarity index 100% rename from src/Components/Inputs/Radio/index.css rename to client/src/Components/Inputs/Radio/index.css diff --git a/src/Components/Inputs/Radio/index.jsx b/client/src/Components/Inputs/Radio/index.jsx similarity index 100% rename from src/Components/Inputs/Radio/index.jsx rename to client/src/Components/Inputs/Radio/index.jsx diff --git a/src/Components/Inputs/Search/index.jsx b/client/src/Components/Inputs/Search/index.jsx similarity index 100% rename from src/Components/Inputs/Search/index.jsx rename to client/src/Components/Inputs/Search/index.jsx diff --git a/src/Components/Inputs/Select/index.css b/client/src/Components/Inputs/Select/index.css similarity index 100% rename from src/Components/Inputs/Select/index.css rename to client/src/Components/Inputs/Select/index.css diff --git a/src/Components/Inputs/Select/index.jsx b/client/src/Components/Inputs/Select/index.jsx similarity index 100% rename from src/Components/Inputs/Select/index.jsx rename to client/src/Components/Inputs/Select/index.jsx diff --git a/src/Components/Inputs/TextInput/Adornments/index.jsx b/client/src/Components/Inputs/TextInput/Adornments/index.jsx similarity index 100% rename from src/Components/Inputs/TextInput/Adornments/index.jsx rename to client/src/Components/Inputs/TextInput/Adornments/index.jsx diff --git a/src/Components/Inputs/TextInput/index.jsx b/client/src/Components/Inputs/TextInput/index.jsx similarity index 100% rename from src/Components/Inputs/TextInput/index.jsx rename to client/src/Components/Inputs/TextInput/index.jsx diff --git a/src/Components/Label/index.css b/client/src/Components/Label/index.css similarity index 100% rename from src/Components/Label/index.css rename to client/src/Components/Label/index.css diff --git a/src/Components/Label/index.jsx b/client/src/Components/Label/index.jsx similarity index 100% rename from src/Components/Label/index.jsx rename to client/src/Components/Label/index.jsx diff --git a/src/Components/LanguageSelector.jsx b/client/src/Components/LanguageSelector.jsx similarity index 100% rename from src/Components/LanguageSelector.jsx rename to client/src/Components/LanguageSelector.jsx diff --git a/src/Components/Layouts/HomeLayout/index.css b/client/src/Components/Layouts/HomeLayout/index.css similarity index 100% rename from src/Components/Layouts/HomeLayout/index.css rename to client/src/Components/Layouts/HomeLayout/index.css diff --git a/src/Components/Layouts/HomeLayout/index.jsx b/client/src/Components/Layouts/HomeLayout/index.jsx similarity index 100% rename from src/Components/Layouts/HomeLayout/index.jsx rename to client/src/Components/Layouts/HomeLayout/index.jsx diff --git a/src/Components/Link/index.jsx b/client/src/Components/Link/index.jsx similarity index 100% rename from src/Components/Link/index.jsx rename to client/src/Components/Link/index.jsx diff --git a/src/Components/Link/link.css b/client/src/Components/Link/link.css similarity index 100% rename from src/Components/Link/link.css rename to client/src/Components/Link/link.css diff --git a/src/Components/MonitorCountHeader/index.jsx b/client/src/Components/MonitorCountHeader/index.jsx similarity index 100% rename from src/Components/MonitorCountHeader/index.jsx rename to client/src/Components/MonitorCountHeader/index.jsx diff --git a/src/Components/MonitorCountHeader/skeleton.jsx b/client/src/Components/MonitorCountHeader/skeleton.jsx similarity index 100% rename from src/Components/MonitorCountHeader/skeleton.jsx rename to client/src/Components/MonitorCountHeader/skeleton.jsx diff --git a/src/Components/MonitorCreateHeader/index.jsx b/client/src/Components/MonitorCreateHeader/index.jsx similarity index 100% rename from src/Components/MonitorCreateHeader/index.jsx rename to client/src/Components/MonitorCreateHeader/index.jsx diff --git a/src/Components/MonitorCreateHeader/skeleton.jsx b/client/src/Components/MonitorCreateHeader/skeleton.jsx similarity index 100% rename from src/Components/MonitorCreateHeader/skeleton.jsx rename to client/src/Components/MonitorCreateHeader/skeleton.jsx diff --git a/src/Components/MonitorStatusHeader/ConfigButton/index.jsx b/client/src/Components/MonitorStatusHeader/ConfigButton/index.jsx similarity index 100% rename from src/Components/MonitorStatusHeader/ConfigButton/index.jsx rename to client/src/Components/MonitorStatusHeader/ConfigButton/index.jsx diff --git a/src/Components/MonitorStatusHeader/index.jsx b/client/src/Components/MonitorStatusHeader/index.jsx similarity index 100% rename from src/Components/MonitorStatusHeader/index.jsx rename to client/src/Components/MonitorStatusHeader/index.jsx diff --git a/src/Components/MonitorStatusHeader/skeleton.jsx b/client/src/Components/MonitorStatusHeader/skeleton.jsx similarity index 100% rename from src/Components/MonitorStatusHeader/skeleton.jsx rename to client/src/Components/MonitorStatusHeader/skeleton.jsx diff --git a/src/Components/MonitorTimeFrameHeader/index.jsx b/client/src/Components/MonitorTimeFrameHeader/index.jsx similarity index 100% rename from src/Components/MonitorTimeFrameHeader/index.jsx rename to client/src/Components/MonitorTimeFrameHeader/index.jsx diff --git a/src/Components/MonitorTimeFrameHeader/skeleton.jsx b/client/src/Components/MonitorTimeFrameHeader/skeleton.jsx similarity index 100% rename from src/Components/MonitorTimeFrameHeader/skeleton.jsx rename to client/src/Components/MonitorTimeFrameHeader/skeleton.jsx diff --git a/src/Components/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx b/client/src/Components/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx similarity index 100% rename from src/Components/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx rename to client/src/Components/NotificationIntegrationModal/Components/NotificationIntegrationModal.jsx diff --git a/src/Components/NotificationIntegrationModal/Components/TabComponent.jsx b/client/src/Components/NotificationIntegrationModal/Components/TabComponent.jsx similarity index 100% rename from src/Components/NotificationIntegrationModal/Components/TabComponent.jsx rename to client/src/Components/NotificationIntegrationModal/Components/TabComponent.jsx diff --git a/src/Components/NotificationIntegrationModal/Components/TabPanel.jsx b/client/src/Components/NotificationIntegrationModal/Components/TabPanel.jsx similarity index 100% rename from src/Components/NotificationIntegrationModal/Components/TabPanel.jsx rename to client/src/Components/NotificationIntegrationModal/Components/TabPanel.jsx diff --git a/src/Components/NotificationIntegrationModal/Hooks/useNotification.js b/client/src/Components/NotificationIntegrationModal/Hooks/useNotification.js similarity index 100% rename from src/Components/NotificationIntegrationModal/Hooks/useNotification.js rename to client/src/Components/NotificationIntegrationModal/Hooks/useNotification.js diff --git a/src/Components/ProgressBars/index.css b/client/src/Components/ProgressBars/index.css similarity index 100% rename from src/Components/ProgressBars/index.css rename to client/src/Components/ProgressBars/index.css diff --git a/src/Components/ProgressBars/index.jsx b/client/src/Components/ProgressBars/index.jsx similarity index 100% rename from src/Components/ProgressBars/index.jsx rename to client/src/Components/ProgressBars/index.jsx diff --git a/src/Components/ProgressStepper/index.css b/client/src/Components/ProgressStepper/index.css similarity index 100% rename from src/Components/ProgressStepper/index.css rename to client/src/Components/ProgressStepper/index.css diff --git a/src/Components/ProgressStepper/index.jsx b/client/src/Components/ProgressStepper/index.jsx similarity index 100% rename from src/Components/ProgressStepper/index.jsx rename to client/src/Components/ProgressStepper/index.jsx diff --git a/src/Components/ProtectedDistributedUptimeRoute/index.jsx b/client/src/Components/ProtectedDistributedUptimeRoute/index.jsx similarity index 100% rename from src/Components/ProtectedDistributedUptimeRoute/index.jsx rename to client/src/Components/ProtectedDistributedUptimeRoute/index.jsx diff --git a/src/Components/ProtectedRoute/index.jsx b/client/src/Components/ProtectedRoute/index.jsx similarity index 100% rename from src/Components/ProtectedRoute/index.jsx rename to client/src/Components/ProtectedRoute/index.jsx diff --git a/src/Components/ShareComponent/index.jsx b/client/src/Components/ShareComponent/index.jsx similarity index 100% rename from src/Components/ShareComponent/index.jsx rename to client/src/Components/ShareComponent/index.jsx diff --git a/src/Components/Sidebar/index.css b/client/src/Components/Sidebar/index.css similarity index 100% rename from src/Components/Sidebar/index.css rename to client/src/Components/Sidebar/index.css diff --git a/src/Components/Sidebar/index.jsx b/client/src/Components/Sidebar/index.jsx similarity index 100% rename from src/Components/Sidebar/index.jsx rename to client/src/Components/Sidebar/index.jsx diff --git a/src/Components/Skeletons/FullPage/index.jsx b/client/src/Components/Skeletons/FullPage/index.jsx similarity index 100% rename from src/Components/Skeletons/FullPage/index.jsx rename to client/src/Components/Skeletons/FullPage/index.jsx diff --git a/src/Components/StandardContainer/index.jsx b/client/src/Components/StandardContainer/index.jsx similarity index 100% rename from src/Components/StandardContainer/index.jsx rename to client/src/Components/StandardContainer/index.jsx diff --git a/src/Components/StarPrompt/index.jsx b/client/src/Components/StarPrompt/index.jsx similarity index 100% rename from src/Components/StarPrompt/index.jsx rename to client/src/Components/StarPrompt/index.jsx diff --git a/src/Components/StatBox/index.jsx b/client/src/Components/StatBox/index.jsx similarity index 100% rename from src/Components/StatBox/index.jsx rename to client/src/Components/StatBox/index.jsx diff --git a/src/Components/StatusBoxes/index.jsx b/client/src/Components/StatusBoxes/index.jsx similarity index 100% rename from src/Components/StatusBoxes/index.jsx rename to client/src/Components/StatusBoxes/index.jsx diff --git a/src/Components/StatusBoxes/skeleton.jsx b/client/src/Components/StatusBoxes/skeleton.jsx similarity index 100% rename from src/Components/StatusBoxes/skeleton.jsx rename to client/src/Components/StatusBoxes/skeleton.jsx diff --git a/src/Components/Subheader/index.jsx b/client/src/Components/Subheader/index.jsx similarity index 100% rename from src/Components/Subheader/index.jsx rename to client/src/Components/Subheader/index.jsx diff --git a/src/Components/Tab/index.jsx b/client/src/Components/Tab/index.jsx similarity index 100% rename from src/Components/Tab/index.jsx rename to client/src/Components/Tab/index.jsx diff --git a/src/Components/TabPanels/Account/PasswordPanel.jsx b/client/src/Components/TabPanels/Account/PasswordPanel.jsx similarity index 100% rename from src/Components/TabPanels/Account/PasswordPanel.jsx rename to client/src/Components/TabPanels/Account/PasswordPanel.jsx diff --git a/src/Components/TabPanels/Account/ProfilePanel.jsx b/client/src/Components/TabPanels/Account/ProfilePanel.jsx similarity index 100% rename from src/Components/TabPanels/Account/ProfilePanel.jsx rename to client/src/Components/TabPanels/Account/ProfilePanel.jsx diff --git a/src/Components/TabPanels/Account/TeamPanel.jsx b/client/src/Components/TabPanels/Account/TeamPanel.jsx similarity index 100% rename from src/Components/TabPanels/Account/TeamPanel.jsx rename to client/src/Components/TabPanels/Account/TeamPanel.jsx diff --git a/src/Components/Table/TablePagination/Actions/index.jsx b/client/src/Components/Table/TablePagination/Actions/index.jsx similarity index 100% rename from src/Components/Table/TablePagination/Actions/index.jsx rename to client/src/Components/Table/TablePagination/Actions/index.jsx diff --git a/src/Components/Table/TablePagination/index.jsx b/client/src/Components/Table/TablePagination/index.jsx similarity index 100% rename from src/Components/Table/TablePagination/index.jsx rename to client/src/Components/Table/TablePagination/index.jsx diff --git a/src/Components/Table/index.jsx b/client/src/Components/Table/index.jsx similarity index 100% rename from src/Components/Table/index.jsx rename to client/src/Components/Table/index.jsx diff --git a/src/Components/Table/skeleton.jsx b/client/src/Components/Table/skeleton.jsx similarity index 100% rename from src/Components/Table/skeleton.jsx rename to client/src/Components/Table/skeleton.jsx diff --git a/src/Components/ThemeSwitch/SunAndMoonIcon.jsx b/client/src/Components/ThemeSwitch/SunAndMoonIcon.jsx similarity index 100% rename from src/Components/ThemeSwitch/SunAndMoonIcon.jsx rename to client/src/Components/ThemeSwitch/SunAndMoonIcon.jsx diff --git a/src/Components/ThemeSwitch/index.css b/client/src/Components/ThemeSwitch/index.css similarity index 100% rename from src/Components/ThemeSwitch/index.css rename to client/src/Components/ThemeSwitch/index.css diff --git a/src/Components/ThemeSwitch/index.jsx b/client/src/Components/ThemeSwitch/index.jsx similarity index 100% rename from src/Components/ThemeSwitch/index.jsx rename to client/src/Components/ThemeSwitch/index.jsx diff --git a/src/Components/WalletProvider/index.css b/client/src/Components/WalletProvider/index.css similarity index 100% rename from src/Components/WalletProvider/index.css rename to client/src/Components/WalletProvider/index.css diff --git a/src/Components/WalletProvider/index.jsx b/client/src/Components/WalletProvider/index.jsx similarity index 100% rename from src/Components/WalletProvider/index.jsx rename to client/src/Components/WalletProvider/index.jsx diff --git a/src/Features/Auth/authSlice.js b/client/src/Features/Auth/authSlice.js similarity index 100% rename from src/Features/Auth/authSlice.js rename to client/src/Features/Auth/authSlice.js diff --git a/src/Features/InfrastructureMonitors/infrastructureMonitorsSlice.js b/client/src/Features/InfrastructureMonitors/infrastructureMonitorsSlice.js similarity index 100% rename from src/Features/InfrastructureMonitors/infrastructureMonitorsSlice.js rename to client/src/Features/InfrastructureMonitors/infrastructureMonitorsSlice.js diff --git a/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js b/client/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js similarity index 100% rename from src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js rename to client/src/Features/PageSpeedMonitor/pageSpeedMonitorSlice.js diff --git a/src/Features/Settings/settingsSlice.js b/client/src/Features/Settings/settingsSlice.js similarity index 100% rename from src/Features/Settings/settingsSlice.js rename to client/src/Features/Settings/settingsSlice.js diff --git a/src/Features/UI/uiSlice.js b/client/src/Features/UI/uiSlice.js similarity index 100% rename from src/Features/UI/uiSlice.js rename to client/src/Features/UI/uiSlice.js diff --git a/src/Features/UptimeMonitors/uptimeMonitorsSlice.js b/client/src/Features/UptimeMonitors/uptimeMonitorsSlice.js similarity index 100% rename from src/Features/UptimeMonitors/uptimeMonitorsSlice.js rename to client/src/Features/UptimeMonitors/uptimeMonitorsSlice.js diff --git a/src/Hooks/inviteHooks.js b/client/src/Hooks/inviteHooks.js similarity index 100% rename from src/Hooks/inviteHooks.js rename to client/src/Hooks/inviteHooks.js diff --git a/src/Hooks/useFetchDepinStatusPage.js b/client/src/Hooks/useFetchDepinStatusPage.js similarity index 100% rename from src/Hooks/useFetchDepinStatusPage.js rename to client/src/Hooks/useFetchDepinStatusPage.js diff --git a/src/Hooks/useFetchMonitorsWithChecks.js b/client/src/Hooks/useFetchMonitorsWithChecks.js similarity index 100% rename from src/Hooks/useFetchMonitorsWithChecks.js rename to client/src/Hooks/useFetchMonitorsWithChecks.js diff --git a/src/Hooks/useFetchMonitorsWithSummary.js b/client/src/Hooks/useFetchMonitorsWithSummary.js similarity index 100% rename from src/Hooks/useFetchMonitorsWithSummary.js rename to client/src/Hooks/useFetchMonitorsWithSummary.js diff --git a/src/Hooks/useFetchUptimeMonitorDetails.js b/client/src/Hooks/useFetchUptimeMonitorDetails.js similarity index 100% rename from src/Hooks/useFetchUptimeMonitorDetails.js rename to client/src/Hooks/useFetchUptimeMonitorDetails.js diff --git a/src/Hooks/useIsAdmin.js b/client/src/Hooks/useIsAdmin.js similarity index 100% rename from src/Hooks/useIsAdmin.js rename to client/src/Hooks/useIsAdmin.js diff --git a/src/Hooks/useMonitorUtils.js b/client/src/Hooks/useMonitorUtils.js similarity index 100% rename from src/Hooks/useMonitorUtils.js rename to client/src/Hooks/useMonitorUtils.js diff --git a/src/Hooks/useSubscribeToDepinDetails.js b/client/src/Hooks/useSubscribeToDepinDetails.js similarity index 100% rename from src/Hooks/useSubscribeToDepinDetails.js rename to client/src/Hooks/useSubscribeToDepinDetails.js diff --git a/src/Hooks/useSubscribeToDepinMonitors.js b/client/src/Hooks/useSubscribeToDepinMonitors.js similarity index 100% rename from src/Hooks/useSubscribeToDepinMonitors.js rename to client/src/Hooks/useSubscribeToDepinMonitors.js diff --git a/src/Pages/About/index.jsx b/client/src/Pages/About/index.jsx similarity index 100% rename from src/Pages/About/index.jsx rename to client/src/Pages/About/index.jsx diff --git a/src/Pages/Account/index.css b/client/src/Pages/Account/index.css similarity index 100% rename from src/Pages/Account/index.css rename to client/src/Pages/Account/index.css diff --git a/src/Pages/Account/index.jsx b/client/src/Pages/Account/index.jsx similarity index 100% rename from src/Pages/Account/index.jsx rename to client/src/Pages/Account/index.jsx diff --git a/src/Pages/Auth/CheckEmail.jsx b/client/src/Pages/Auth/CheckEmail.jsx similarity index 100% rename from src/Pages/Auth/CheckEmail.jsx rename to client/src/Pages/Auth/CheckEmail.jsx diff --git a/src/Pages/Auth/ForgotPassword.jsx b/client/src/Pages/Auth/ForgotPassword.jsx similarity index 100% rename from src/Pages/Auth/ForgotPassword.jsx rename to client/src/Pages/Auth/ForgotPassword.jsx diff --git a/src/Pages/Auth/Login/Components/EmailStep.jsx b/client/src/Pages/Auth/Login/Components/EmailStep.jsx similarity index 100% rename from src/Pages/Auth/Login/Components/EmailStep.jsx rename to client/src/Pages/Auth/Login/Components/EmailStep.jsx diff --git a/src/Pages/Auth/Login/Components/ForgotPasswordLabel.jsx b/client/src/Pages/Auth/Login/Components/ForgotPasswordLabel.jsx similarity index 100% rename from src/Pages/Auth/Login/Components/ForgotPasswordLabel.jsx rename to client/src/Pages/Auth/Login/Components/ForgotPasswordLabel.jsx diff --git a/src/Pages/Auth/Login/Components/PasswordStep.jsx b/client/src/Pages/Auth/Login/Components/PasswordStep.jsx similarity index 100% rename from src/Pages/Auth/Login/Components/PasswordStep.jsx rename to client/src/Pages/Auth/Login/Components/PasswordStep.jsx diff --git a/src/Pages/Auth/Login/Login.jsx b/client/src/Pages/Auth/Login/Login.jsx similarity index 100% rename from src/Pages/Auth/Login/Login.jsx rename to client/src/Pages/Auth/Login/Login.jsx diff --git a/src/Pages/Auth/NewPasswordConfirmed.jsx b/client/src/Pages/Auth/NewPasswordConfirmed.jsx similarity index 100% rename from src/Pages/Auth/NewPasswordConfirmed.jsx rename to client/src/Pages/Auth/NewPasswordConfirmed.jsx diff --git a/src/Pages/Auth/Register/Register.jsx b/client/src/Pages/Auth/Register/Register.jsx similarity index 100% rename from src/Pages/Auth/Register/Register.jsx rename to client/src/Pages/Auth/Register/Register.jsx diff --git a/src/Pages/Auth/Register/StepOne/index.jsx b/client/src/Pages/Auth/Register/StepOne/index.jsx similarity index 100% rename from src/Pages/Auth/Register/StepOne/index.jsx rename to client/src/Pages/Auth/Register/StepOne/index.jsx diff --git a/src/Pages/Auth/Register/StepThree/index.jsx b/client/src/Pages/Auth/Register/StepThree/index.jsx similarity index 100% rename from src/Pages/Auth/Register/StepThree/index.jsx rename to client/src/Pages/Auth/Register/StepThree/index.jsx diff --git a/src/Pages/Auth/Register/StepTwo/index.jsx b/client/src/Pages/Auth/Register/StepTwo/index.jsx similarity index 100% rename from src/Pages/Auth/Register/StepTwo/index.jsx rename to client/src/Pages/Auth/Register/StepTwo/index.jsx diff --git a/src/Pages/Auth/SetNewPassword.jsx b/client/src/Pages/Auth/SetNewPassword.jsx similarity index 100% rename from src/Pages/Auth/SetNewPassword.jsx rename to client/src/Pages/Auth/SetNewPassword.jsx diff --git a/src/Pages/Auth/hooks/useValidatePassword.jsx b/client/src/Pages/Auth/hooks/useValidatePassword.jsx similarity index 100% rename from src/Pages/Auth/hooks/useValidatePassword.jsx rename to client/src/Pages/Auth/hooks/useValidatePassword.jsx diff --git a/src/Pages/Auth/index.css b/client/src/Pages/Auth/index.css similarity index 100% rename from src/Pages/Auth/index.css rename to client/src/Pages/Auth/index.css diff --git a/src/Pages/DistributedUptime/Create/Hooks/useCreateDistributedUptimeMonitor.jsx b/client/src/Pages/DistributedUptime/Create/Hooks/useCreateDistributedUptimeMonitor.jsx similarity index 100% rename from src/Pages/DistributedUptime/Create/Hooks/useCreateDistributedUptimeMonitor.jsx rename to client/src/Pages/DistributedUptime/Create/Hooks/useCreateDistributedUptimeMonitor.jsx diff --git a/src/Pages/DistributedUptime/Create/Hooks/useMonitorFetch.jsx b/client/src/Pages/DistributedUptime/Create/Hooks/useMonitorFetch.jsx similarity index 100% rename from src/Pages/DistributedUptime/Create/Hooks/useMonitorFetch.jsx rename to client/src/Pages/DistributedUptime/Create/Hooks/useMonitorFetch.jsx diff --git a/src/Pages/DistributedUptime/Create/index.jsx b/client/src/Pages/DistributedUptime/Create/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Create/index.jsx rename to client/src/Pages/DistributedUptime/Create/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/Chatbot/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/Chatbot/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/Chatbot/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/Chatbot/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/ControlsHeader/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/ControlsHeader/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/ControlsHeader/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/ControlsHeader/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/DeviceTicker/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/DeviceTicker/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/DeviceTicker/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/DeviceTicker/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/DistributedUptimeMapStyle.json b/client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/DistributedUptimeMapStyle.json similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/DistributedUptimeMapStyle.json rename to client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/DistributedUptimeMapStyle.json diff --git a/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/buildStyle.js b/client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/buildStyle.js similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/buildStyle.js rename to client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/buildStyle.js diff --git a/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeMap/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Area/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Area/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Area/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Area/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Bar/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Bar/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Bar/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Bar/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Helpers/Tick.jsx b/client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Helpers/Tick.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Helpers/Tick.jsx rename to client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Helpers/Tick.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Helpers/ToolTip.jsx b/client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Helpers/ToolTip.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Helpers/ToolTip.jsx rename to client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/Helpers/ToolTip.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/DistributedUptimeResponseChart/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/Footer/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/Footer/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/Footer/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/Footer/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/LastUpdate/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/LastUpdate/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/LastUpdate/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/LastUpdate/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/MonitorHeader/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/MonitorHeader/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/MonitorHeader/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/MonitorHeader/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/NextExpectedCheck/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/NextExpectedCheck/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/NextExpectedCheck/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/NextExpectedCheck/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/Skeleton/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/Skeleton/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/Skeleton/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/Skeleton/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/StatBoxes/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/StatBoxes/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/StatBoxes/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/StatBoxes/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Components/StatusHeader/index.jsx b/client/src/Pages/DistributedUptime/Details/Components/StatusHeader/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Components/StatusHeader/index.jsx rename to client/src/Pages/DistributedUptime/Details/Components/StatusHeader/index.jsx diff --git a/src/Pages/DistributedUptime/Details/Hooks/useDeleteMonitor.jsx b/client/src/Pages/DistributedUptime/Details/Hooks/useDeleteMonitor.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/Hooks/useDeleteMonitor.jsx rename to client/src/Pages/DistributedUptime/Details/Hooks/useDeleteMonitor.jsx diff --git a/src/Pages/DistributedUptime/Details/index.jsx b/client/src/Pages/DistributedUptime/Details/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Details/index.jsx rename to client/src/Pages/DistributedUptime/Details/index.jsx diff --git a/src/Pages/DistributedUptime/Monitors/Components/MonitorTable/index.jsx b/client/src/Pages/DistributedUptime/Monitors/Components/MonitorTable/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Monitors/Components/MonitorTable/index.jsx rename to client/src/Pages/DistributedUptime/Monitors/Components/MonitorTable/index.jsx diff --git a/src/Pages/DistributedUptime/Monitors/Components/Skeleton/index.jsx b/client/src/Pages/DistributedUptime/Monitors/Components/Skeleton/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Monitors/Components/Skeleton/index.jsx rename to client/src/Pages/DistributedUptime/Monitors/Components/Skeleton/index.jsx diff --git a/src/Pages/DistributedUptime/Monitors/index.jsx b/client/src/Pages/DistributedUptime/Monitors/index.jsx similarity index 100% rename from src/Pages/DistributedUptime/Monitors/index.jsx rename to client/src/Pages/DistributedUptime/Monitors/index.jsx diff --git a/src/Pages/DistributedUptimeStatus/Create/Components/VisuallyHiddenInput/index.jsx b/client/src/Pages/DistributedUptimeStatus/Create/Components/VisuallyHiddenInput/index.jsx similarity index 100% rename from src/Pages/DistributedUptimeStatus/Create/Components/VisuallyHiddenInput/index.jsx rename to client/src/Pages/DistributedUptimeStatus/Create/Components/VisuallyHiddenInput/index.jsx diff --git a/src/Pages/DistributedUptimeStatus/Create/index.jsx b/client/src/Pages/DistributedUptimeStatus/Create/index.jsx similarity index 100% rename from src/Pages/DistributedUptimeStatus/Create/index.jsx rename to client/src/Pages/DistributedUptimeStatus/Create/index.jsx diff --git a/src/Pages/DistributedUptimeStatus/Status/Components/MonitorsList/index.jsx b/client/src/Pages/DistributedUptimeStatus/Status/Components/MonitorsList/index.jsx similarity index 100% rename from src/Pages/DistributedUptimeStatus/Status/Components/MonitorsList/index.jsx rename to client/src/Pages/DistributedUptimeStatus/Status/Components/MonitorsList/index.jsx diff --git a/src/Pages/DistributedUptimeStatus/Status/Components/Skeleton/index.jsx b/client/src/Pages/DistributedUptimeStatus/Status/Components/Skeleton/index.jsx similarity index 100% rename from src/Pages/DistributedUptimeStatus/Status/Components/Skeleton/index.jsx rename to client/src/Pages/DistributedUptimeStatus/Status/Components/Skeleton/index.jsx diff --git a/src/Pages/DistributedUptimeStatus/Status/Components/TimeframeHeader/index.jsx b/client/src/Pages/DistributedUptimeStatus/Status/Components/TimeframeHeader/index.jsx similarity index 100% rename from src/Pages/DistributedUptimeStatus/Status/Components/TimeframeHeader/index.jsx rename to client/src/Pages/DistributedUptimeStatus/Status/Components/TimeframeHeader/index.jsx diff --git a/src/Pages/DistributedUptimeStatus/Status/index.jsx b/client/src/Pages/DistributedUptimeStatus/Status/index.jsx similarity index 100% rename from src/Pages/DistributedUptimeStatus/Status/index.jsx rename to client/src/Pages/DistributedUptimeStatus/Status/index.jsx diff --git a/src/Pages/Incidents/Components/IncidentTable/index.jsx b/client/src/Pages/Incidents/Components/IncidentTable/index.jsx similarity index 100% rename from src/Pages/Incidents/Components/IncidentTable/index.jsx rename to client/src/Pages/Incidents/Components/IncidentTable/index.jsx diff --git a/src/Pages/Incidents/Components/OptionsHeader/index.jsx b/client/src/Pages/Incidents/Components/OptionsHeader/index.jsx similarity index 100% rename from src/Pages/Incidents/Components/OptionsHeader/index.jsx rename to client/src/Pages/Incidents/Components/OptionsHeader/index.jsx diff --git a/src/Pages/Incidents/Components/OptionsHeader/skeleton.jsx b/client/src/Pages/Incidents/Components/OptionsHeader/skeleton.jsx similarity index 100% rename from src/Pages/Incidents/Components/OptionsHeader/skeleton.jsx rename to client/src/Pages/Incidents/Components/OptionsHeader/skeleton.jsx diff --git a/src/Pages/Incidents/Hooks/useChecksFetch.jsx b/client/src/Pages/Incidents/Hooks/useChecksFetch.jsx similarity index 100% rename from src/Pages/Incidents/Hooks/useChecksFetch.jsx rename to client/src/Pages/Incidents/Hooks/useChecksFetch.jsx diff --git a/src/Pages/Incidents/Hooks/useMonitorsFetch.jsx b/client/src/Pages/Incidents/Hooks/useMonitorsFetch.jsx similarity index 100% rename from src/Pages/Incidents/Hooks/useMonitorsFetch.jsx rename to client/src/Pages/Incidents/Hooks/useMonitorsFetch.jsx diff --git a/src/Pages/Incidents/index.jsx b/client/src/Pages/Incidents/index.jsx similarity index 100% rename from src/Pages/Incidents/index.jsx rename to client/src/Pages/Incidents/index.jsx diff --git a/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx b/client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx rename to client/src/Pages/Infrastructure/Create/Components/CustomThreshold/index.jsx diff --git a/src/Pages/Infrastructure/Create/index.jsx b/client/src/Pages/Infrastructure/Create/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Create/index.jsx rename to client/src/Pages/Infrastructure/Create/index.jsx diff --git a/src/Pages/Infrastructure/Details/Components/AreaChartBoxes/InfraAreaChart.jsx b/client/src/Pages/Infrastructure/Details/Components/AreaChartBoxes/InfraAreaChart.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/Components/AreaChartBoxes/InfraAreaChart.jsx rename to client/src/Pages/Infrastructure/Details/Components/AreaChartBoxes/InfraAreaChart.jsx diff --git a/src/Pages/Infrastructure/Details/Components/AreaChartBoxes/index.jsx b/client/src/Pages/Infrastructure/Details/Components/AreaChartBoxes/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/Components/AreaChartBoxes/index.jsx rename to client/src/Pages/Infrastructure/Details/Components/AreaChartBoxes/index.jsx diff --git a/src/Pages/Infrastructure/Details/Components/AreaChartBoxes/skeleton.jsx b/client/src/Pages/Infrastructure/Details/Components/AreaChartBoxes/skeleton.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/Components/AreaChartBoxes/skeleton.jsx rename to client/src/Pages/Infrastructure/Details/Components/AreaChartBoxes/skeleton.jsx diff --git a/src/Pages/Infrastructure/Details/Components/BaseContainer/index.jsx b/client/src/Pages/Infrastructure/Details/Components/BaseContainer/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/Components/BaseContainer/index.jsx rename to client/src/Pages/Infrastructure/Details/Components/BaseContainer/index.jsx diff --git a/src/Pages/Infrastructure/Details/Components/GaugeBoxes/Gauge.jsx b/client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/Gauge.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/Components/GaugeBoxes/Gauge.jsx rename to client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/Gauge.jsx diff --git a/src/Pages/Infrastructure/Details/Components/GaugeBoxes/index.jsx b/client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/Components/GaugeBoxes/index.jsx rename to client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/index.jsx diff --git a/src/Pages/Infrastructure/Details/Components/GaugeBoxes/skeleton.jsx b/client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/skeleton.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/Components/GaugeBoxes/skeleton.jsx rename to client/src/Pages/Infrastructure/Details/Components/GaugeBoxes/skeleton.jsx diff --git a/src/Pages/Infrastructure/Details/Components/StatusBoxes/index.jsx b/client/src/Pages/Infrastructure/Details/Components/StatusBoxes/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/Components/StatusBoxes/index.jsx rename to client/src/Pages/Infrastructure/Details/Components/StatusBoxes/index.jsx diff --git a/src/Pages/Infrastructure/Details/Hooks/useHardwareMonitorsFetch.jsx b/client/src/Pages/Infrastructure/Details/Hooks/useHardwareMonitorsFetch.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/Hooks/useHardwareMonitorsFetch.jsx rename to client/src/Pages/Infrastructure/Details/Hooks/useHardwareMonitorsFetch.jsx diff --git a/src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx b/client/src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx rename to client/src/Pages/Infrastructure/Details/Hooks/useHardwareUtils.jsx diff --git a/src/Pages/Infrastructure/Details/index.jsx b/client/src/Pages/Infrastructure/Details/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Details/index.jsx rename to client/src/Pages/Infrastructure/Details/index.jsx diff --git a/src/Pages/Infrastructure/Monitors/Components/Filters/index.jsx b/client/src/Pages/Infrastructure/Monitors/Components/Filters/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Monitors/Components/Filters/index.jsx rename to client/src/Pages/Infrastructure/Monitors/Components/Filters/index.jsx diff --git a/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx rename to client/src/Pages/Infrastructure/Monitors/Components/MonitorsTable/index.jsx diff --git a/src/Pages/Infrastructure/Monitors/Components/MonitorsTableMenu/index.jsx b/client/src/Pages/Infrastructure/Monitors/Components/MonitorsTableMenu/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Monitors/Components/MonitorsTableMenu/index.jsx rename to client/src/Pages/Infrastructure/Monitors/Components/MonitorsTableMenu/index.jsx diff --git a/src/Pages/Infrastructure/Monitors/Hooks/useMonitorFetch.jsx b/client/src/Pages/Infrastructure/Monitors/Hooks/useMonitorFetch.jsx similarity index 100% rename from src/Pages/Infrastructure/Monitors/Hooks/useMonitorFetch.jsx rename to client/src/Pages/Infrastructure/Monitors/Hooks/useMonitorFetch.jsx diff --git a/src/Pages/Infrastructure/Monitors/index.jsx b/client/src/Pages/Infrastructure/Monitors/index.jsx similarity index 100% rename from src/Pages/Infrastructure/Monitors/index.jsx rename to client/src/Pages/Infrastructure/Monitors/index.jsx diff --git a/src/Pages/Integrations/index.css b/client/src/Pages/Integrations/index.css similarity index 100% rename from src/Pages/Integrations/index.css rename to client/src/Pages/Integrations/index.css diff --git a/src/Pages/Integrations/index.jsx b/client/src/Pages/Integrations/index.jsx similarity index 100% rename from src/Pages/Integrations/index.jsx rename to client/src/Pages/Integrations/index.jsx diff --git a/src/Pages/Maintenance/CreateMaintenance/Components/MonitorsConfig/index.jsx b/client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorsConfig/index.jsx similarity index 100% rename from src/Pages/Maintenance/CreateMaintenance/Components/MonitorsConfig/index.jsx rename to client/src/Pages/Maintenance/CreateMaintenance/Components/MonitorsConfig/index.jsx diff --git a/src/Pages/Maintenance/CreateMaintenance/index.css b/client/src/Pages/Maintenance/CreateMaintenance/index.css similarity index 100% rename from src/Pages/Maintenance/CreateMaintenance/index.css rename to client/src/Pages/Maintenance/CreateMaintenance/index.css diff --git a/src/Pages/Maintenance/CreateMaintenance/index.jsx b/client/src/Pages/Maintenance/CreateMaintenance/index.jsx similarity index 100% rename from src/Pages/Maintenance/CreateMaintenance/index.jsx rename to client/src/Pages/Maintenance/CreateMaintenance/index.jsx diff --git a/src/Pages/Maintenance/MaintenanceTable/ActionsMenu/index.jsx b/client/src/Pages/Maintenance/MaintenanceTable/ActionsMenu/index.jsx similarity index 100% rename from src/Pages/Maintenance/MaintenanceTable/ActionsMenu/index.jsx rename to client/src/Pages/Maintenance/MaintenanceTable/ActionsMenu/index.jsx diff --git a/src/Pages/Maintenance/MaintenanceTable/index.jsx b/client/src/Pages/Maintenance/MaintenanceTable/index.jsx similarity index 100% rename from src/Pages/Maintenance/MaintenanceTable/index.jsx rename to client/src/Pages/Maintenance/MaintenanceTable/index.jsx diff --git a/src/Pages/Maintenance/index.css b/client/src/Pages/Maintenance/index.css similarity index 100% rename from src/Pages/Maintenance/index.css rename to client/src/Pages/Maintenance/index.css diff --git a/src/Pages/Maintenance/index.jsx b/client/src/Pages/Maintenance/index.jsx similarity index 100% rename from src/Pages/Maintenance/index.jsx rename to client/src/Pages/Maintenance/index.jsx diff --git a/src/Pages/NotFound/index.jsx b/client/src/Pages/NotFound/index.jsx similarity index 100% rename from src/Pages/NotFound/index.jsx rename to client/src/Pages/NotFound/index.jsx diff --git a/src/Pages/PageSpeed/Configure/index.css b/client/src/Pages/PageSpeed/Configure/index.css similarity index 100% rename from src/Pages/PageSpeed/Configure/index.css rename to client/src/Pages/PageSpeed/Configure/index.css diff --git a/src/Pages/PageSpeed/Configure/index.jsx b/client/src/Pages/PageSpeed/Configure/index.jsx similarity index 100% rename from src/Pages/PageSpeed/Configure/index.jsx rename to client/src/Pages/PageSpeed/Configure/index.jsx diff --git a/src/Pages/PageSpeed/Configure/skeleton.jsx b/client/src/Pages/PageSpeed/Configure/skeleton.jsx similarity index 100% rename from src/Pages/PageSpeed/Configure/skeleton.jsx rename to client/src/Pages/PageSpeed/Configure/skeleton.jsx diff --git a/src/Pages/PageSpeed/Create/index.css b/client/src/Pages/PageSpeed/Create/index.css similarity index 100% rename from src/Pages/PageSpeed/Create/index.css rename to client/src/Pages/PageSpeed/Create/index.css diff --git a/src/Pages/PageSpeed/Create/index.jsx b/client/src/Pages/PageSpeed/Create/index.jsx similarity index 100% rename from src/Pages/PageSpeed/Create/index.jsx rename to client/src/Pages/PageSpeed/Create/index.jsx diff --git a/src/Pages/PageSpeed/Details/Components/Charts/AreaChart.jsx b/client/src/Pages/PageSpeed/Details/Components/Charts/AreaChart.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/Components/Charts/AreaChart.jsx rename to client/src/Pages/PageSpeed/Details/Components/Charts/AreaChart.jsx diff --git a/src/Pages/PageSpeed/Details/Components/Charts/AreaChartLegend.jsx b/client/src/Pages/PageSpeed/Details/Components/Charts/AreaChartLegend.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/Components/Charts/AreaChartLegend.jsx rename to client/src/Pages/PageSpeed/Details/Components/Charts/AreaChartLegend.jsx diff --git a/src/Pages/PageSpeed/Details/Components/Charts/PieChart.jsx b/client/src/Pages/PageSpeed/Details/Components/Charts/PieChart.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/Components/Charts/PieChart.jsx rename to client/src/Pages/PageSpeed/Details/Components/Charts/PieChart.jsx diff --git a/src/Pages/PageSpeed/Details/Components/Charts/PieChartLegend.jsx b/client/src/Pages/PageSpeed/Details/Components/Charts/PieChartLegend.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/Components/Charts/PieChartLegend.jsx rename to client/src/Pages/PageSpeed/Details/Components/Charts/PieChartLegend.jsx diff --git a/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/index.jsx b/client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/index.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/index.jsx rename to client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/index.jsx diff --git a/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/skeleton.jsx b/client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/skeleton.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/skeleton.jsx rename to client/src/Pages/PageSpeed/Details/Components/PageSpeedAreaChart/skeleton.jsx diff --git a/src/Pages/PageSpeed/Details/Components/PageSpeedStatusBoxes/index.jsx b/client/src/Pages/PageSpeed/Details/Components/PageSpeedStatusBoxes/index.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/Components/PageSpeedStatusBoxes/index.jsx rename to client/src/Pages/PageSpeed/Details/Components/PageSpeedStatusBoxes/index.jsx diff --git a/src/Pages/PageSpeed/Details/Components/PerformanceReport/index.jsx b/client/src/Pages/PageSpeed/Details/Components/PerformanceReport/index.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/Components/PerformanceReport/index.jsx rename to client/src/Pages/PageSpeed/Details/Components/PerformanceReport/index.jsx diff --git a/src/Pages/PageSpeed/Details/Components/PerformanceReport/skeleton.jsx b/client/src/Pages/PageSpeed/Details/Components/PerformanceReport/skeleton.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/Components/PerformanceReport/skeleton.jsx rename to client/src/Pages/PageSpeed/Details/Components/PerformanceReport/skeleton.jsx diff --git a/src/Pages/PageSpeed/Details/Hooks/useMonitorFetch.jsx b/client/src/Pages/PageSpeed/Details/Hooks/useMonitorFetch.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/Hooks/useMonitorFetch.jsx rename to client/src/Pages/PageSpeed/Details/Hooks/useMonitorFetch.jsx diff --git a/src/Pages/PageSpeed/Details/index.jsx b/client/src/Pages/PageSpeed/Details/index.jsx similarity index 100% rename from src/Pages/PageSpeed/Details/index.jsx rename to client/src/Pages/PageSpeed/Details/index.jsx diff --git a/src/Pages/PageSpeed/Monitors/Components/Card/index.jsx b/client/src/Pages/PageSpeed/Monitors/Components/Card/index.jsx similarity index 100% rename from src/Pages/PageSpeed/Monitors/Components/Card/index.jsx rename to client/src/Pages/PageSpeed/Monitors/Components/Card/index.jsx diff --git a/src/Pages/PageSpeed/Monitors/Components/MonitorGrid/index.jsx b/client/src/Pages/PageSpeed/Monitors/Components/MonitorGrid/index.jsx similarity index 100% rename from src/Pages/PageSpeed/Monitors/Components/MonitorGrid/index.jsx rename to client/src/Pages/PageSpeed/Monitors/Components/MonitorGrid/index.jsx diff --git a/src/Pages/PageSpeed/Monitors/Hooks/useMonitorsFetch.jsx b/client/src/Pages/PageSpeed/Monitors/Hooks/useMonitorsFetch.jsx similarity index 100% rename from src/Pages/PageSpeed/Monitors/Hooks/useMonitorsFetch.jsx rename to client/src/Pages/PageSpeed/Monitors/Hooks/useMonitorsFetch.jsx diff --git a/src/Pages/PageSpeed/Monitors/index.jsx b/client/src/Pages/PageSpeed/Monitors/index.jsx similarity index 100% rename from src/Pages/PageSpeed/Monitors/index.jsx rename to client/src/Pages/PageSpeed/Monitors/index.jsx diff --git a/src/Pages/Settings/index.jsx b/client/src/Pages/Settings/index.jsx similarity index 100% rename from src/Pages/Settings/index.jsx rename to client/src/Pages/Settings/index.jsx diff --git a/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx b/client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx similarity index 100% rename from src/Pages/StatusPage/Create/Components/MonitorList/index.jsx rename to client/src/Pages/StatusPage/Create/Components/MonitorList/index.jsx diff --git a/src/Pages/StatusPage/Create/Components/Progress/index.jsx b/client/src/Pages/StatusPage/Create/Components/Progress/index.jsx similarity index 100% rename from src/Pages/StatusPage/Create/Components/Progress/index.jsx rename to client/src/Pages/StatusPage/Create/Components/Progress/index.jsx diff --git a/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx b/client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx similarity index 100% rename from src/Pages/StatusPage/Create/Components/Skeleton/index.jsx rename to client/src/Pages/StatusPage/Create/Components/Skeleton/index.jsx diff --git a/src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx similarity index 100% rename from src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx rename to client/src/Pages/StatusPage/Create/Components/Tabs/ConfigStack.jsx diff --git a/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx similarity index 100% rename from src/Pages/StatusPage/Create/Components/Tabs/Content.jsx rename to client/src/Pages/StatusPage/Create/Components/Tabs/Content.jsx diff --git a/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx similarity index 100% rename from src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx rename to client/src/Pages/StatusPage/Create/Components/Tabs/Settings.jsx diff --git a/src/Pages/StatusPage/Create/Components/Tabs/index.jsx b/client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx similarity index 100% rename from src/Pages/StatusPage/Create/Components/Tabs/index.jsx rename to client/src/Pages/StatusPage/Create/Components/Tabs/index.jsx diff --git a/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx b/client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx similarity index 100% rename from src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx rename to client/src/Pages/StatusPage/Create/Hooks/useCreateStatusPage.jsx diff --git a/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx b/client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx similarity index 100% rename from src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx rename to client/src/Pages/StatusPage/Create/Hooks/useMonitorsFetch.jsx diff --git a/src/Pages/StatusPage/Create/index.jsx b/client/src/Pages/StatusPage/Create/index.jsx similarity index 100% rename from src/Pages/StatusPage/Create/index.jsx rename to client/src/Pages/StatusPage/Create/index.jsx diff --git a/src/Pages/StatusPage/Status/Components/AdminLink/index.jsx b/client/src/Pages/StatusPage/Status/Components/AdminLink/index.jsx similarity index 100% rename from src/Pages/StatusPage/Status/Components/AdminLink/index.jsx rename to client/src/Pages/StatusPage/Status/Components/AdminLink/index.jsx diff --git a/src/Pages/StatusPage/Status/Components/ControlsHeader/index.jsx b/client/src/Pages/StatusPage/Status/Components/ControlsHeader/index.jsx similarity index 100% rename from src/Pages/StatusPage/Status/Components/ControlsHeader/index.jsx rename to client/src/Pages/StatusPage/Status/Components/ControlsHeader/index.jsx diff --git a/src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx b/client/src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx similarity index 100% rename from src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx rename to client/src/Pages/StatusPage/Status/Components/MonitorsList/index.jsx diff --git a/src/Pages/StatusPage/Status/Components/Skeleton/index.jsx b/client/src/Pages/StatusPage/Status/Components/Skeleton/index.jsx similarity index 100% rename from src/Pages/StatusPage/Status/Components/Skeleton/index.jsx rename to client/src/Pages/StatusPage/Status/Components/Skeleton/index.jsx diff --git a/src/Pages/StatusPage/Status/Components/StatusBar/index.jsx b/client/src/Pages/StatusPage/Status/Components/StatusBar/index.jsx similarity index 100% rename from src/Pages/StatusPage/Status/Components/StatusBar/index.jsx rename to client/src/Pages/StatusPage/Status/Components/StatusBar/index.jsx diff --git a/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx b/client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx similarity index 100% rename from src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx rename to client/src/Pages/StatusPage/Status/Hooks/useStatusPageDelete.jsx diff --git a/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx b/client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx similarity index 100% rename from src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx rename to client/src/Pages/StatusPage/Status/Hooks/useStatusPageFetch.jsx diff --git a/src/Pages/StatusPage/Status/index.jsx b/client/src/Pages/StatusPage/Status/index.jsx similarity index 100% rename from src/Pages/StatusPage/Status/index.jsx rename to client/src/Pages/StatusPage/Status/index.jsx diff --git a/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx b/client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx similarity index 100% rename from src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx rename to client/src/Pages/StatusPage/StatusPages/Components/StatusPagesTable/index.jsx diff --git a/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx b/client/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx similarity index 100% rename from src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx rename to client/src/Pages/StatusPage/StatusPages/Hooks/useStatusPagesFetch.jsx diff --git a/src/Pages/StatusPage/StatusPages/index.jsx b/client/src/Pages/StatusPage/StatusPages/index.jsx similarity index 100% rename from src/Pages/StatusPage/StatusPages/index.jsx rename to client/src/Pages/StatusPage/StatusPages/index.jsx diff --git a/src/Pages/Uptime/Configure/index.css b/client/src/Pages/Uptime/Configure/index.css similarity index 100% rename from src/Pages/Uptime/Configure/index.css rename to client/src/Pages/Uptime/Configure/index.css diff --git a/src/Pages/Uptime/Configure/index.jsx b/client/src/Pages/Uptime/Configure/index.jsx similarity index 100% rename from src/Pages/Uptime/Configure/index.jsx rename to client/src/Pages/Uptime/Configure/index.jsx diff --git a/src/Pages/Uptime/Configure/skeleton.jsx b/client/src/Pages/Uptime/Configure/skeleton.jsx similarity index 100% rename from src/Pages/Uptime/Configure/skeleton.jsx rename to client/src/Pages/Uptime/Configure/skeleton.jsx diff --git a/src/Pages/Uptime/Create/index.css b/client/src/Pages/Uptime/Create/index.css similarity index 100% rename from src/Pages/Uptime/Create/index.css rename to client/src/Pages/Uptime/Create/index.css diff --git a/src/Pages/Uptime/Create/index.jsx b/client/src/Pages/Uptime/Create/index.jsx similarity index 100% rename from src/Pages/Uptime/Create/index.jsx rename to client/src/Pages/Uptime/Create/index.jsx diff --git a/src/Pages/Uptime/Details/Components/ChartBoxes/index.jsx b/client/src/Pages/Uptime/Details/Components/ChartBoxes/index.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/ChartBoxes/index.jsx rename to client/src/Pages/Uptime/Details/Components/ChartBoxes/index.jsx diff --git a/src/Pages/Uptime/Details/Components/ChartBoxes/skeleton.jsx b/client/src/Pages/Uptime/Details/Components/ChartBoxes/skeleton.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/ChartBoxes/skeleton.jsx rename to client/src/Pages/Uptime/Details/Components/ChartBoxes/skeleton.jsx diff --git a/src/Pages/Uptime/Details/Components/Charts/CustomLabels.jsx b/client/src/Pages/Uptime/Details/Components/Charts/CustomLabels.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/Charts/CustomLabels.jsx rename to client/src/Pages/Uptime/Details/Components/Charts/CustomLabels.jsx diff --git a/src/Pages/Uptime/Details/Components/Charts/DownBarChart.jsx b/client/src/Pages/Uptime/Details/Components/Charts/DownBarChart.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/Charts/DownBarChart.jsx rename to client/src/Pages/Uptime/Details/Components/Charts/DownBarChart.jsx diff --git a/src/Pages/Uptime/Details/Components/Charts/ResponseGaugeChart.jsx b/client/src/Pages/Uptime/Details/Components/Charts/ResponseGaugeChart.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/Charts/ResponseGaugeChart.jsx rename to client/src/Pages/Uptime/Details/Components/Charts/ResponseGaugeChart.jsx diff --git a/src/Pages/Uptime/Details/Components/Charts/ResponseTimeChart.jsx b/client/src/Pages/Uptime/Details/Components/Charts/ResponseTimeChart.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/Charts/ResponseTimeChart.jsx rename to client/src/Pages/Uptime/Details/Components/Charts/ResponseTimeChart.jsx diff --git a/src/Pages/Uptime/Details/Components/Charts/ResponseTimeChartSkeleton.jsx b/client/src/Pages/Uptime/Details/Components/Charts/ResponseTimeChartSkeleton.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/Charts/ResponseTimeChartSkeleton.jsx rename to client/src/Pages/Uptime/Details/Components/Charts/ResponseTimeChartSkeleton.jsx diff --git a/src/Pages/Uptime/Details/Components/Charts/UpBarChart.jsx b/client/src/Pages/Uptime/Details/Components/Charts/UpBarChart.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/Charts/UpBarChart.jsx rename to client/src/Pages/Uptime/Details/Components/Charts/UpBarChart.jsx diff --git a/src/Pages/Uptime/Details/Components/ResponseTable/index.jsx b/client/src/Pages/Uptime/Details/Components/ResponseTable/index.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/ResponseTable/index.jsx rename to client/src/Pages/Uptime/Details/Components/ResponseTable/index.jsx diff --git a/src/Pages/Uptime/Details/Components/ResponseTable/skeleton.jsx b/client/src/Pages/Uptime/Details/Components/ResponseTable/skeleton.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/ResponseTable/skeleton.jsx rename to client/src/Pages/Uptime/Details/Components/ResponseTable/skeleton.jsx diff --git a/src/Pages/Uptime/Details/Components/UptimeStatusBoxes/index.jsx b/client/src/Pages/Uptime/Details/Components/UptimeStatusBoxes/index.jsx similarity index 100% rename from src/Pages/Uptime/Details/Components/UptimeStatusBoxes/index.jsx rename to client/src/Pages/Uptime/Details/Components/UptimeStatusBoxes/index.jsx diff --git a/src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx b/client/src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx similarity index 100% rename from src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx rename to client/src/Pages/Uptime/Details/Hooks/useCertificateFetch.jsx diff --git a/src/Pages/Uptime/Details/Hooks/useChecksFetch.jsx b/client/src/Pages/Uptime/Details/Hooks/useChecksFetch.jsx similarity index 100% rename from src/Pages/Uptime/Details/Hooks/useChecksFetch.jsx rename to client/src/Pages/Uptime/Details/Hooks/useChecksFetch.jsx diff --git a/src/Pages/Uptime/Details/Hooks/useMonitorFetch.jsx b/client/src/Pages/Uptime/Details/Hooks/useMonitorFetch.jsx similarity index 100% rename from src/Pages/Uptime/Details/Hooks/useMonitorFetch.jsx rename to client/src/Pages/Uptime/Details/Hooks/useMonitorFetch.jsx diff --git a/src/Pages/Uptime/Details/index.jsx b/client/src/Pages/Uptime/Details/index.jsx similarity index 100% rename from src/Pages/Uptime/Details/index.jsx rename to client/src/Pages/Uptime/Details/index.jsx diff --git a/src/Pages/Uptime/Monitors/Components/Filter/index.jsx b/client/src/Pages/Uptime/Monitors/Components/Filter/index.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Components/Filter/index.jsx rename to client/src/Pages/Uptime/Monitors/Components/Filter/index.jsx diff --git a/src/Pages/Uptime/Monitors/Components/LoadingSpinner/index.jsx b/client/src/Pages/Uptime/Monitors/Components/LoadingSpinner/index.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Components/LoadingSpinner/index.jsx rename to client/src/Pages/Uptime/Monitors/Components/LoadingSpinner/index.jsx diff --git a/src/Pages/Uptime/Monitors/Components/SearchComponent/index.jsx b/client/src/Pages/Uptime/Monitors/Components/SearchComponent/index.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Components/SearchComponent/index.jsx rename to client/src/Pages/Uptime/Monitors/Components/SearchComponent/index.jsx diff --git a/src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx b/client/src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx rename to client/src/Pages/Uptime/Monitors/Components/Skeleton/index.jsx diff --git a/src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx b/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx rename to client/src/Pages/Uptime/Monitors/Components/StatusBoxes/index.jsx diff --git a/src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx b/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx rename to client/src/Pages/Uptime/Monitors/Components/StatusBoxes/skeleton.jsx diff --git a/src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx b/client/src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx rename to client/src/Pages/Uptime/Monitors/Components/StatusBoxes/statusBox.jsx diff --git a/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx b/client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx rename to client/src/Pages/Uptime/Monitors/Components/UptimeDataTable/index.jsx diff --git a/src/Pages/Uptime/Monitors/Hooks/useDebounce.jsx b/client/src/Pages/Uptime/Monitors/Hooks/useDebounce.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Hooks/useDebounce.jsx rename to client/src/Pages/Uptime/Monitors/Hooks/useDebounce.jsx diff --git a/src/Pages/Uptime/Monitors/Hooks/useMonitorsFetch.jsx b/client/src/Pages/Uptime/Monitors/Hooks/useMonitorsFetch.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Hooks/useMonitorsFetch.jsx rename to client/src/Pages/Uptime/Monitors/Hooks/useMonitorsFetch.jsx diff --git a/src/Pages/Uptime/Monitors/Hooks/useUtils.jsx b/client/src/Pages/Uptime/Monitors/Hooks/useUtils.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/Hooks/useUtils.jsx rename to client/src/Pages/Uptime/Monitors/Hooks/useUtils.jsx diff --git a/src/Pages/Uptime/Monitors/index.jsx b/client/src/Pages/Uptime/Monitors/index.jsx similarity index 100% rename from src/Pages/Uptime/Monitors/index.jsx rename to client/src/Pages/Uptime/Monitors/index.jsx diff --git a/src/Routes/index.jsx b/client/src/Routes/index.jsx similarity index 100% rename from src/Routes/index.jsx rename to client/src/Routes/index.jsx diff --git a/src/Utils/Logger.js b/client/src/Utils/Logger.js similarity index 100% rename from src/Utils/Logger.js rename to client/src/Utils/Logger.js diff --git a/src/Utils/NetworkService.js b/client/src/Utils/NetworkService.js similarity index 100% rename from src/Utils/NetworkService.js rename to client/src/Utils/NetworkService.js diff --git a/src/Utils/NetworkServiceProvider.jsx b/client/src/Utils/NetworkServiceProvider.jsx similarity index 100% rename from src/Utils/NetworkServiceProvider.jsx rename to client/src/Utils/NetworkServiceProvider.jsx diff --git a/src/Utils/ReadMe.md b/client/src/Utils/ReadMe.md similarity index 100% rename from src/Utils/ReadMe.md rename to client/src/Utils/ReadMe.md diff --git a/src/Utils/Theme/constants.js b/client/src/Utils/Theme/constants.js similarity index 100% rename from src/Utils/Theme/constants.js rename to client/src/Utils/Theme/constants.js diff --git a/src/Utils/Theme/darkTheme.js b/client/src/Utils/Theme/darkTheme.js similarity index 100% rename from src/Utils/Theme/darkTheme.js rename to client/src/Utils/Theme/darkTheme.js diff --git a/src/Utils/Theme/extractColorObject.js b/client/src/Utils/Theme/extractColorObject.js similarity index 100% rename from src/Utils/Theme/extractColorObject.js rename to client/src/Utils/Theme/extractColorObject.js diff --git a/src/Utils/Theme/globalTheme.js b/client/src/Utils/Theme/globalTheme.js similarity index 100% rename from src/Utils/Theme/globalTheme.js rename to client/src/Utils/Theme/globalTheme.js diff --git a/src/Utils/Theme/lightTheme.js b/client/src/Utils/Theme/lightTheme.js similarity index 100% rename from src/Utils/Theme/lightTheme.js rename to client/src/Utils/Theme/lightTheme.js diff --git a/src/Utils/debounce.jsx b/client/src/Utils/debounce.jsx similarity index 100% rename from src/Utils/debounce.jsx rename to client/src/Utils/debounce.jsx diff --git a/src/Utils/fileUtils.js b/client/src/Utils/fileUtils.js similarity index 100% rename from src/Utils/fileUtils.js rename to client/src/Utils/fileUtils.js diff --git a/src/Utils/greeting.jsx b/client/src/Utils/greeting.jsx similarity index 100% rename from src/Utils/greeting.jsx rename to client/src/Utils/greeting.jsx diff --git a/src/Utils/i18n.js b/client/src/Utils/i18n.js similarity index 100% rename from src/Utils/i18n.js rename to client/src/Utils/i18n.js diff --git a/src/Utils/monitorUtils.js b/client/src/Utils/monitorUtils.js similarity index 100% rename from src/Utils/monitorUtils.js rename to client/src/Utils/monitorUtils.js diff --git a/src/Utils/stringUtils.js b/client/src/Utils/stringUtils.js similarity index 100% rename from src/Utils/stringUtils.js rename to client/src/Utils/stringUtils.js diff --git a/src/Utils/timeUtils.js b/client/src/Utils/timeUtils.js similarity index 100% rename from src/Utils/timeUtils.js rename to client/src/Utils/timeUtils.js diff --git a/src/Utils/timezones.json b/client/src/Utils/timezones.json similarity index 100% rename from src/Utils/timezones.json rename to client/src/Utils/timezones.json diff --git a/src/Utils/toastUtils.jsx b/client/src/Utils/toastUtils.jsx similarity index 100% rename from src/Utils/toastUtils.jsx rename to client/src/Utils/toastUtils.jsx diff --git a/src/Utils/utils.js b/client/src/Utils/utils.js similarity index 100% rename from src/Utils/utils.js rename to client/src/Utils/utils.js diff --git a/src/Validation/error.js b/client/src/Validation/error.js similarity index 100% rename from src/Validation/error.js rename to client/src/Validation/error.js diff --git a/src/Validation/validation.js b/client/src/Validation/validation.js similarity index 100% rename from src/Validation/validation.js rename to client/src/Validation/validation.js diff --git a/src/assets/Images/Google.png b/client/src/assets/Images/Google.png similarity index 100% rename from src/assets/Images/Google.png rename to client/src/assets/Images/Google.png diff --git a/src/assets/Images/avatar_placeholder.png b/client/src/assets/Images/avatar_placeholder.png similarity index 100% rename from src/assets/Images/avatar_placeholder.png rename to client/src/assets/Images/avatar_placeholder.png diff --git a/src/assets/Images/background-grid.svg b/client/src/assets/Images/background-grid.svg similarity index 100% rename from src/assets/Images/background-grid.svg rename to client/src/assets/Images/background-grid.svg diff --git a/src/assets/Images/bwl-logo.svg b/client/src/assets/Images/bwl-logo.svg similarity index 100% rename from src/assets/Images/bwl-logo.svg rename to client/src/assets/Images/bwl-logo.svg diff --git a/src/assets/Images/create-placeholder-dark.svg b/client/src/assets/Images/create-placeholder-dark.svg similarity index 100% rename from src/assets/Images/create-placeholder-dark.svg rename to client/src/assets/Images/create-placeholder-dark.svg diff --git a/src/assets/Images/create-placeholder.svg b/client/src/assets/Images/create-placeholder.svg similarity index 100% rename from src/assets/Images/create-placeholder.svg rename to client/src/assets/Images/create-placeholder.svg diff --git a/src/assets/Images/data_placeholder.svg b/client/src/assets/Images/data_placeholder.svg similarity index 100% rename from src/assets/Images/data_placeholder.svg rename to client/src/assets/Images/data_placeholder.svg diff --git a/src/assets/Images/data_placeholder_dark.svg b/client/src/assets/Images/data_placeholder_dark.svg similarity index 100% rename from src/assets/Images/data_placeholder_dark.svg rename to client/src/assets/Images/data_placeholder_dark.svg diff --git a/src/assets/Images/jupiter_logo_banner_dark.svg b/client/src/assets/Images/jupiter_logo_banner_dark.svg similarity index 100% rename from src/assets/Images/jupiter_logo_banner_dark.svg rename to client/src/assets/Images/jupiter_logo_banner_dark.svg diff --git a/src/assets/Images/jupiter_logo_banner_light.svg b/client/src/assets/Images/jupiter_logo_banner_light.svg similarity index 100% rename from src/assets/Images/jupiter_logo_banner_light.svg rename to client/src/assets/Images/jupiter_logo_banner_light.svg diff --git a/src/assets/Images/logo_placeholder.svg b/client/src/assets/Images/logo_placeholder.svg similarity index 100% rename from src/assets/Images/logo_placeholder.svg rename to client/src/assets/Images/logo_placeholder.svg diff --git a/src/assets/Images/solana_logo_banner_dark.svg b/client/src/assets/Images/solana_logo_banner_dark.svg similarity index 100% rename from src/assets/Images/solana_logo_banner_dark.svg rename to client/src/assets/Images/solana_logo_banner_dark.svg diff --git a/src/assets/Images/solana_logo_banner_light.svg b/client/src/assets/Images/solana_logo_banner_light.svg similarity index 100% rename from src/assets/Images/solana_logo_banner_light.svg rename to client/src/assets/Images/solana_logo_banner_light.svg diff --git a/src/assets/Images/sushi_404.svg b/client/src/assets/Images/sushi_404.svg similarity index 100% rename from src/assets/Images/sushi_404.svg rename to client/src/assets/Images/sushi_404.svg diff --git a/src/assets/icons/average-response-icon.svg b/client/src/assets/icons/average-response-icon.svg similarity index 100% rename from src/assets/icons/average-response-icon.svg rename to client/src/assets/icons/average-response-icon.svg diff --git a/src/assets/icons/bwu-icon.svg b/client/src/assets/icons/bwu-icon.svg similarity index 100% rename from src/assets/icons/bwu-icon.svg rename to client/src/assets/icons/bwu-icon.svg diff --git a/src/assets/icons/calendar-check.svg b/client/src/assets/icons/calendar-check.svg similarity index 100% rename from src/assets/icons/calendar-check.svg rename to client/src/assets/icons/calendar-check.svg diff --git a/src/assets/icons/calendar.svg b/client/src/assets/icons/calendar.svg similarity index 100% rename from src/assets/icons/calendar.svg rename to client/src/assets/icons/calendar.svg diff --git a/src/assets/icons/certificate.svg b/client/src/assets/icons/certificate.svg similarity index 100% rename from src/assets/icons/certificate.svg rename to client/src/assets/icons/certificate.svg diff --git a/src/assets/icons/changeLog.svg b/client/src/assets/icons/changeLog.svg similarity index 100% rename from src/assets/icons/changeLog.svg rename to client/src/assets/icons/changeLog.svg diff --git a/src/assets/icons/check-outlined.svg b/client/src/assets/icons/check-outlined.svg similarity index 100% rename from src/assets/icons/check-outlined.svg rename to client/src/assets/icons/check-outlined.svg diff --git a/src/assets/icons/check.svg b/client/src/assets/icons/check.svg similarity index 100% rename from src/assets/icons/check.svg rename to client/src/assets/icons/check.svg diff --git a/src/assets/icons/checkbox-filled.svg b/client/src/assets/icons/checkbox-filled.svg similarity index 100% rename from src/assets/icons/checkbox-filled.svg rename to client/src/assets/icons/checkbox-filled.svg diff --git a/src/assets/icons/checkbox-green.svg b/client/src/assets/icons/checkbox-green.svg similarity index 100% rename from src/assets/icons/checkbox-green.svg rename to client/src/assets/icons/checkbox-green.svg diff --git a/src/assets/icons/checkbox-outline.svg b/client/src/assets/icons/checkbox-outline.svg similarity index 100% rename from src/assets/icons/checkbox-outline.svg rename to client/src/assets/icons/checkbox-outline.svg diff --git a/src/assets/icons/checkbox-red.svg b/client/src/assets/icons/checkbox-red.svg similarity index 100% rename from src/assets/icons/checkbox-red.svg rename to client/src/assets/icons/checkbox-red.svg diff --git a/src/assets/icons/checkmate-icon.svg b/client/src/assets/icons/checkmate-icon.svg similarity index 100% rename from src/assets/icons/checkmate-icon.svg rename to client/src/assets/icons/checkmate-icon.svg diff --git a/src/assets/icons/clock-snooze.svg b/client/src/assets/icons/clock-snooze.svg similarity index 100% rename from src/assets/icons/clock-snooze.svg rename to client/src/assets/icons/clock-snooze.svg diff --git a/src/assets/icons/cpu-chip.svg b/client/src/assets/icons/cpu-chip.svg similarity index 100% rename from src/assets/icons/cpu-chip.svg rename to client/src/assets/icons/cpu-chip.svg diff --git a/src/assets/icons/dashboard.svg b/client/src/assets/icons/dashboard.svg similarity index 100% rename from src/assets/icons/dashboard.svg rename to client/src/assets/icons/dashboard.svg diff --git a/src/assets/icons/discord-icon.svg b/client/src/assets/icons/discord-icon.svg similarity index 100% rename from src/assets/icons/discord-icon.svg rename to client/src/assets/icons/discord-icon.svg diff --git a/src/assets/icons/discussions.svg b/client/src/assets/icons/discussions.svg similarity index 100% rename from src/assets/icons/discussions.svg rename to client/src/assets/icons/discussions.svg diff --git a/src/assets/icons/distributed-uptime.svg b/client/src/assets/icons/distributed-uptime.svg similarity index 100% rename from src/assets/icons/distributed-uptime.svg rename to client/src/assets/icons/distributed-uptime.svg diff --git a/src/assets/icons/docs.svg b/client/src/assets/icons/docs.svg similarity index 100% rename from src/assets/icons/docs.svg rename to client/src/assets/icons/docs.svg diff --git a/src/assets/icons/dots-vertical.svg b/client/src/assets/icons/dots-vertical.svg similarity index 100% rename from src/assets/icons/dots-vertical.svg rename to client/src/assets/icons/dots-vertical.svg diff --git a/src/assets/icons/down-arrow.svg b/client/src/assets/icons/down-arrow.svg similarity index 100% rename from src/assets/icons/down-arrow.svg rename to client/src/assets/icons/down-arrow.svg diff --git a/src/assets/icons/edit.svg b/client/src/assets/icons/edit.svg similarity index 100% rename from src/assets/icons/edit.svg rename to client/src/assets/icons/edit.svg diff --git a/src/assets/icons/email.svg b/client/src/assets/icons/email.svg similarity index 100% rename from src/assets/icons/email.svg rename to client/src/assets/icons/email.svg diff --git a/src/assets/icons/folder.svg b/client/src/assets/icons/folder.svg similarity index 100% rename from src/assets/icons/folder.svg rename to client/src/assets/icons/folder.svg diff --git a/src/assets/icons/groups.svg b/client/src/assets/icons/groups.svg similarity index 100% rename from src/assets/icons/groups.svg rename to client/src/assets/icons/groups.svg diff --git a/src/assets/icons/history-icon.svg b/client/src/assets/icons/history-icon.svg similarity index 100% rename from src/assets/icons/history-icon.svg rename to client/src/assets/icons/history-icon.svg diff --git a/src/assets/icons/incidents.svg b/client/src/assets/icons/incidents.svg similarity index 100% rename from src/assets/icons/incidents.svg rename to client/src/assets/icons/incidents.svg diff --git a/src/assets/icons/integrations.svg b/client/src/assets/icons/integrations.svg similarity index 100% rename from src/assets/icons/integrations.svg rename to client/src/assets/icons/integrations.svg diff --git a/src/assets/icons/interval-check.svg b/client/src/assets/icons/interval-check.svg similarity index 100% rename from src/assets/icons/interval-check.svg rename to client/src/assets/icons/interval-check.svg diff --git a/src/assets/icons/jupiter_ag_logo.svg b/client/src/assets/icons/jupiter_ag_logo.svg similarity index 100% rename from src/assets/icons/jupiter_ag_logo.svg rename to client/src/assets/icons/jupiter_ag_logo.svg diff --git a/src/assets/icons/jupiter_logo_banner_dark.svg b/client/src/assets/icons/jupiter_logo_banner_dark.svg similarity index 100% rename from src/assets/icons/jupiter_logo_banner_dark.svg rename to client/src/assets/icons/jupiter_logo_banner_dark.svg diff --git a/src/assets/icons/jupiter_logo_banner_light.svg b/client/src/assets/icons/jupiter_logo_banner_light.svg similarity index 100% rename from src/assets/icons/jupiter_logo_banner_light.svg rename to client/src/assets/icons/jupiter_logo_banner_light.svg diff --git a/src/assets/icons/key.svg b/client/src/assets/icons/key.svg similarity index 100% rename from src/assets/icons/key.svg rename to client/src/assets/icons/key.svg diff --git a/src/assets/icons/left-arrow-double.svg b/client/src/assets/icons/left-arrow-double.svg similarity index 100% rename from src/assets/icons/left-arrow-double.svg rename to client/src/assets/icons/left-arrow-double.svg diff --git a/src/assets/icons/left-arrow-long.svg b/client/src/assets/icons/left-arrow-long.svg similarity index 100% rename from src/assets/icons/left-arrow-long.svg rename to client/src/assets/icons/left-arrow-long.svg diff --git a/src/assets/icons/left-arrow.svg b/client/src/assets/icons/left-arrow.svg similarity index 100% rename from src/assets/icons/left-arrow.svg rename to client/src/assets/icons/left-arrow.svg diff --git a/src/assets/icons/lock.svg b/client/src/assets/icons/lock.svg similarity index 100% rename from src/assets/icons/lock.svg rename to client/src/assets/icons/lock.svg diff --git a/src/assets/icons/logout.svg b/client/src/assets/icons/logout.svg similarity index 100% rename from src/assets/icons/logout.svg rename to client/src/assets/icons/logout.svg diff --git a/src/assets/icons/mail.svg b/client/src/assets/icons/mail.svg similarity index 100% rename from src/assets/icons/mail.svg rename to client/src/assets/icons/mail.svg diff --git a/src/assets/icons/maintenance.svg b/client/src/assets/icons/maintenance.svg similarity index 100% rename from src/assets/icons/maintenance.svg rename to client/src/assets/icons/maintenance.svg diff --git a/src/assets/icons/monitor-graph-line.svg b/client/src/assets/icons/monitor-graph-line.svg similarity index 100% rename from src/assets/icons/monitor-graph-line.svg rename to client/src/assets/icons/monitor-graph-line.svg diff --git a/src/assets/icons/monitors.svg b/client/src/assets/icons/monitors.svg similarity index 100% rename from src/assets/icons/monitors.svg rename to client/src/assets/icons/monitors.svg diff --git a/src/assets/icons/open-in-new-page.svg b/client/src/assets/icons/open-in-new-page.svg similarity index 100% rename from src/assets/icons/open-in-new-page.svg rename to client/src/assets/icons/open-in-new-page.svg diff --git a/src/assets/icons/page-speed.svg b/client/src/assets/icons/page-speed.svg similarity index 100% rename from src/assets/icons/page-speed.svg rename to client/src/assets/icons/page-speed.svg diff --git a/src/assets/icons/pause-icon.svg b/client/src/assets/icons/pause-icon.svg similarity index 100% rename from src/assets/icons/pause-icon.svg rename to client/src/assets/icons/pause-icon.svg diff --git a/src/assets/icons/performance-report.svg b/client/src/assets/icons/performance-report.svg similarity index 100% rename from src/assets/icons/performance-report.svg rename to client/src/assets/icons/performance-report.svg diff --git a/src/assets/icons/radio-checked.svg b/client/src/assets/icons/radio-checked.svg similarity index 100% rename from src/assets/icons/radio-checked.svg rename to client/src/assets/icons/radio-checked.svg diff --git a/src/assets/icons/response-time-icon.svg b/client/src/assets/icons/response-time-icon.svg similarity index 100% rename from src/assets/icons/response-time-icon.svg rename to client/src/assets/icons/response-time-icon.svg diff --git a/src/assets/icons/resume-icon.svg b/client/src/assets/icons/resume-icon.svg similarity index 100% rename from src/assets/icons/resume-icon.svg rename to client/src/assets/icons/resume-icon.svg diff --git a/src/assets/icons/right-arrow-double.svg b/client/src/assets/icons/right-arrow-double.svg similarity index 100% rename from src/assets/icons/right-arrow-double.svg rename to client/src/assets/icons/right-arrow-double.svg diff --git a/src/assets/icons/right-arrow.svg b/client/src/assets/icons/right-arrow.svg similarity index 100% rename from src/assets/icons/right-arrow.svg rename to client/src/assets/icons/right-arrow.svg diff --git a/src/assets/icons/ruler-icon.svg b/client/src/assets/icons/ruler-icon.svg similarity index 100% rename from src/assets/icons/ruler-icon.svg rename to client/src/assets/icons/ruler-icon.svg diff --git a/src/assets/icons/search.svg b/client/src/assets/icons/search.svg similarity index 100% rename from src/assets/icons/search.svg rename to client/src/assets/icons/search.svg diff --git a/src/assets/icons/selector-vertical.svg b/client/src/assets/icons/selector-vertical.svg similarity index 100% rename from src/assets/icons/selector-vertical.svg rename to client/src/assets/icons/selector-vertical.svg diff --git a/src/assets/icons/settings-bold.svg b/client/src/assets/icons/settings-bold.svg similarity index 100% rename from src/assets/icons/settings-bold.svg rename to client/src/assets/icons/settings-bold.svg diff --git a/src/assets/icons/settings.svg b/client/src/assets/icons/settings.svg similarity index 100% rename from src/assets/icons/settings.svg rename to client/src/assets/icons/settings.svg diff --git a/src/assets/icons/slack-icon.svg b/client/src/assets/icons/slack-icon.svg similarity index 100% rename from src/assets/icons/slack-icon.svg rename to client/src/assets/icons/slack-icon.svg diff --git a/src/assets/icons/solana_logo.svg b/client/src/assets/icons/solana_logo.svg similarity index 100% rename from src/assets/icons/solana_logo.svg rename to client/src/assets/icons/solana_logo.svg diff --git a/src/assets/icons/solana_logo_banner.svg b/client/src/assets/icons/solana_logo_banner.svg similarity index 100% rename from src/assets/icons/solana_logo_banner.svg rename to client/src/assets/icons/solana_logo_banner.svg diff --git a/src/assets/icons/spedometer-icon.svg b/client/src/assets/icons/spedometer-icon.svg similarity index 100% rename from src/assets/icons/spedometer-icon.svg rename to client/src/assets/icons/spedometer-icon.svg diff --git a/src/assets/icons/speedometer-icon.svg b/client/src/assets/icons/speedometer-icon.svg similarity index 100% rename from src/assets/icons/speedometer-icon.svg rename to client/src/assets/icons/speedometer-icon.svg diff --git a/src/assets/icons/status-pages.svg b/client/src/assets/icons/status-pages.svg similarity index 100% rename from src/assets/icons/status-pages.svg rename to client/src/assets/icons/status-pages.svg diff --git a/src/assets/icons/support.svg b/client/src/assets/icons/support.svg similarity index 100% rename from src/assets/icons/support.svg rename to client/src/assets/icons/support.svg diff --git a/src/assets/icons/top-right-arrow.svg b/client/src/assets/icons/top-right-arrow.svg similarity index 100% rename from src/assets/icons/top-right-arrow.svg rename to client/src/assets/icons/top-right-arrow.svg diff --git a/src/assets/icons/trash-bin.svg b/client/src/assets/icons/trash-bin.svg similarity index 100% rename from src/assets/icons/trash-bin.svg rename to client/src/assets/icons/trash-bin.svg diff --git a/src/assets/icons/up-arrow.svg b/client/src/assets/icons/up-arrow.svg similarity index 100% rename from src/assets/icons/up-arrow.svg rename to client/src/assets/icons/up-arrow.svg diff --git a/src/assets/icons/upt_logo.png b/client/src/assets/icons/upt_logo.png similarity index 100% rename from src/assets/icons/upt_logo.png rename to client/src/assets/icons/upt_logo.png diff --git a/src/assets/icons/uptime-icon.svg b/client/src/assets/icons/uptime-icon.svg similarity index 100% rename from src/assets/icons/uptime-icon.svg rename to client/src/assets/icons/uptime-icon.svg diff --git a/src/assets/icons/user-edit.svg b/client/src/assets/icons/user-edit.svg similarity index 100% rename from src/assets/icons/user-edit.svg rename to client/src/assets/icons/user-edit.svg diff --git a/src/assets/icons/user-two.svg b/client/src/assets/icons/user-two.svg similarity index 100% rename from src/assets/icons/user-two.svg rename to client/src/assets/icons/user-two.svg diff --git a/src/assets/icons/user.svg b/client/src/assets/icons/user.svg similarity index 100% rename from src/assets/icons/user.svg rename to client/src/assets/icons/user.svg diff --git a/src/assets/icons/zapier-icon.svg b/client/src/assets/icons/zapier-icon.svg similarity index 100% rename from src/assets/icons/zapier-icon.svg rename to client/src/assets/icons/zapier-icon.svg diff --git a/src/index.css b/client/src/index.css similarity index 100% rename from src/index.css rename to client/src/index.css diff --git a/src/locales/gb.json b/client/src/locales/gb.json similarity index 100% rename from src/locales/gb.json rename to client/src/locales/gb.json diff --git a/src/locales/ru.json b/client/src/locales/ru.json similarity index 100% rename from src/locales/ru.json rename to client/src/locales/ru.json diff --git a/src/locales/tr.json b/client/src/locales/tr.json similarity index 100% rename from src/locales/tr.json rename to client/src/locales/tr.json diff --git a/src/main.jsx b/client/src/main.jsx similarity index 100% rename from src/main.jsx rename to client/src/main.jsx diff --git a/src/store.js b/client/src/store.js similarity index 100% rename from src/store.js rename to client/src/store.js diff --git a/vite.config.js b/client/vite.config.js similarity index 100% rename from vite.config.js rename to client/vite.config.js diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100755 index 000000000..9b501e10d --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,11 @@ +*.sh +!quickstart.sh +!build_images.sh +dev/mongo/data/* +dev/redis/data/* +dist/mongo/data/* +dist/redis/data/* +prod/mongo/data/* +prod/redis/data/* +*.env +prod/certbot/* \ No newline at end of file diff --git a/docker/coolify/build_images.sh b/docker/coolify/build_images.sh new file mode 100755 index 000000000..179b50b5b --- /dev/null +++ b/docker/coolify/build_images.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Change directory to root Server directory for correct Docker Context +cd "$(dirname "$0")" +cd ../../../ + +# Define an array of services and their Dockerfiles +declare -A services=( + ["bluewaveuptime/uptime_client"]="./server/docker/dist/client.Dockerfile" + ["bluewaveuptime/uptime_database_mongo"]="./server/docker/dist/mongoDB.Dockerfile" + ["bluewaveuptime/uptime_redis"]="./server/docker/dist/redis.Dockerfile" + ["bluewaveuptime/uptime_server"]="./server/docker/dist/server.Dockerfile" +) + +# Loop through each service and build the corresponding image +for service in "${!services[@]}"; do + docker build -f "${services[$service]}" -t "$service" . + + # Check if the build succeeded + if [ $? -ne 0 ]; then + echo "Error building $service image. Exiting..." + exit 1 + fi +done + +echo "All images built successfully" diff --git a/docker/coolify/client.Dockerfile b/docker/coolify/client.Dockerfile new file mode 100755 index 000000000..1384bfa2c --- /dev/null +++ b/docker/coolify/client.Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-alpine AS build + +ENV NODE_OPTIONS="--max-old-space-size=4096" + +WORKDIR /app + +RUN apk add --no-cache \ + python3 \ + make g++ \ + gcc \ + libc-dev \ + linux-headers \ + libusb-dev \ + eudev-dev + +COPY ./client/package*.json ./ + +RUN npm install + +COPY ./client . + +RUN npm run build + +FROM nginx:1.27.1-alpine + +COPY ./server/docker/dist/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +COPY --from=build /app/env.sh /docker-entrypoint.d/env.sh +RUN chmod +x /docker-entrypoint.d/env.sh + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/coolify/mongoDB.Dockerfile b/docker/coolify/mongoDB.Dockerfile new file mode 100755 index 000000000..969a320c2 --- /dev/null +++ b/docker/coolify/mongoDB.Dockerfile @@ -0,0 +1,3 @@ +FROM mongo +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/coolify/nginx/conf.d/default.conf b/docker/coolify/nginx/conf.d/default.conf new file mode 100755 index 000000000..0592dcd06 --- /dev/null +++ b/docker/coolify/nginx/conf.d/default.conf @@ -0,0 +1,35 @@ +server { + listen 80; + listen [::]:80; + + server_name checkmate-demo.bluewavelabs.ca; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server:5000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api-docs/ { + proxy_pass http://server:5000/api-docs/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/docker/coolify/redis.Dockerfile b/docker/coolify/redis.Dockerfile new file mode 100755 index 000000000..af68ec61e --- /dev/null +++ b/docker/coolify/redis.Dockerfile @@ -0,0 +1,2 @@ +FROM redis +EXPOSE 6379 \ No newline at end of file diff --git a/docker/coolify/server.Dockerfile b/docker/coolify/server.Dockerfile new file mode 100755 index 000000000..611a325a5 --- /dev/null +++ b/docker/coolify/server.Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY ../../package*.json ./ + +RUN npm install + +COPY ../../ ./ + +EXPOSE 5000 + +CMD ["node", "index.js"] \ No newline at end of file diff --git a/docker/dev/build_images.sh b/docker/dev/build_images.sh new file mode 100755 index 000000000..509dbe263 --- /dev/null +++ b/docker/dev/build_images.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Change directory to root Server directory for correct Docker Context +cd "$(dirname "$0")" +cd ../.. + +# Define an array of services and their Dockerfiles +declare -A services=( + ["uptime_client"]="./docker/dev/client.Dockerfile" + ["uptime_database_mongo"]="./docker/dev/mongoDB.Dockerfile" + ["uptime_redis"]="./docker/dev/redis.Dockerfile" + ["uptime_server"]="./docker/dev/server.Dockerfile" +) + +# Loop through each service and build the corresponding image +for service in "${!services[@]}"; do + docker build -f "${services[$service]}" -t "$service" . + + ## Check if the build succeeded + if [ $? -ne 0 ]; then + echo "Error building $service image. Exiting..." + exit 1 + fi +done + +echo "All images built successfully" diff --git a/docker/dev/client.Dockerfile b/docker/dev/client.Dockerfile new file mode 100755 index 000000000..fd60f47a2 --- /dev/null +++ b/docker/dev/client.Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-alpine AS build + +ENV NODE_OPTIONS="--max-old-space-size=4096" + +WORKDIR /app + +RUN apk add --no-cache \ + python3 \ + make g++ \ + gcc \ + libc-dev \ + linux-headers \ + libusb-dev \ + eudev-dev + + +COPY ./client/package*.json ./ + +RUN npm install + +COPY ./client . + +RUN npm run build-dev + +RUN npm install -g serve + +CMD ["serve","-s", "dist", "-l", "5173"] diff --git a/docker/dev/docker-compose.yaml b/docker/dev/docker-compose.yaml new file mode 100755 index 000000000..f8b2c6936 --- /dev/null +++ b/docker/dev/docker-compose.yaml @@ -0,0 +1,40 @@ +services: + client: + image: uptime_client:latest + restart: always + ports: + - "5173:5173" + + depends_on: + - server + server: + image: uptime_server:latest + restart: always + ports: + - "5000:5000" + env_file: + - server.env + depends_on: + - redis + - mongodb + redis: + image: uptime_redis:latest + restart: always + ports: + - "6379:6379" + volumes: + - ./redis/data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 5s + mongodb: + image: uptime_database_mongo:latest + restart: always + command: ["mongod", "--quiet"] + ports: + - "27017:27017" + volumes: + - ./mongo/data:/data/db diff --git a/docker/dev/mongoDB.Dockerfile b/docker/dev/mongoDB.Dockerfile new file mode 100755 index 000000000..969a320c2 --- /dev/null +++ b/docker/dev/mongoDB.Dockerfile @@ -0,0 +1,3 @@ +FROM mongo +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/dev/redis.Dockerfile b/docker/dev/redis.Dockerfile new file mode 100755 index 000000000..af68ec61e --- /dev/null +++ b/docker/dev/redis.Dockerfile @@ -0,0 +1,2 @@ +FROM redis +EXPOSE 6379 \ No newline at end of file diff --git a/docker/dev/server.Dockerfile b/docker/dev/server.Dockerfile new file mode 100755 index 000000000..202422dcc --- /dev/null +++ b/docker/dev/server.Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY ./server/package*.json ./ + +RUN npm install + +COPY ./server/ ./ + +EXPOSE 5000 + +CMD ["node", "index.js"] \ No newline at end of file diff --git a/docker/dist/build_images.sh b/docker/dist/build_images.sh new file mode 100755 index 000000000..b18920505 --- /dev/null +++ b/docker/dist/build_images.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Change directory to root Server directory for correct Docker Context +cd "$(dirname "$0")" +cd ../../../ + +# Define an array of services and their Dockerfiles +declare -A services=( + ["bluewaveuptime/uptime_client"]=".docker/dist/client.Dockerfile" + ["bluewaveuptime/uptime_database_mongo"]="./docker/dist/mongoDB.Dockerfile" + ["bluewaveuptime/uptime_redis"]="./docker/dist/redis.Dockerfile" + ["bluewaveuptime/uptime_server"]="./docker/dist/server.Dockerfile" +) + +# Loop through each service and build the corresponding image +for service in "${!services[@]}"; do + docker build -f "${services[$service]}" -t "$service" . + + # Check if the build succeeded + if [ $? -ne 0 ]; then + echo "Error building $service image. Exiting..." + exit 1 + fi +done + +echo "All images built successfully" diff --git a/docker/dist/client.Dockerfile b/docker/dist/client.Dockerfile new file mode 100755 index 000000000..1384bfa2c --- /dev/null +++ b/docker/dist/client.Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-alpine AS build + +ENV NODE_OPTIONS="--max-old-space-size=4096" + +WORKDIR /app + +RUN apk add --no-cache \ + python3 \ + make g++ \ + gcc \ + libc-dev \ + linux-headers \ + libusb-dev \ + eudev-dev + +COPY ./client/package*.json ./ + +RUN npm install + +COPY ./client . + +RUN npm run build + +FROM nginx:1.27.1-alpine + +COPY ./server/docker/dist/nginx/conf.d/default.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +COPY --from=build /app/env.sh /docker-entrypoint.d/env.sh +RUN chmod +x /docker-entrypoint.d/env.sh + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/dist/docker-compose.yaml b/docker/dist/docker-compose.yaml new file mode 100755 index 000000000..420941e7e --- /dev/null +++ b/docker/dist/docker-compose.yaml @@ -0,0 +1,46 @@ +services: + client: + image: bluewaveuptime/uptime_client:latest + restart: always + environment: + UPTIME_APP_API_BASE_URL: "http://localhost:5000/api/v1" + UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/" + ports: + - "80:80" + - "443:443" + depends_on: + - server + server: + image: bluewaveuptime/uptime_server:latest + restart: always + ports: + - "5000:5000" + depends_on: + - redis + - mongodb + environment: + - DB_CONNECTION_STRING=mongodb://mongodb:27017/uptime_db + - REDIS_HOST=redis + # volumes: + # - /var/run/docker.sock:/var/run/docker.sock:ro + redis: + image: bluewaveuptime/uptime_redis:latest + restart: always + ports: + - "6379:6379" + volumes: + - ./redis/data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 5s + mongodb: + image: bluewaveuptime/uptime_database_mongo:latest + restart: always + volumes: + - ./mongo/data:/data/db + command: ["mongod", "--quiet"] + ports: + - "27017:27017" diff --git a/docker/dist/mongoDB.Dockerfile b/docker/dist/mongoDB.Dockerfile new file mode 100755 index 000000000..969a320c2 --- /dev/null +++ b/docker/dist/mongoDB.Dockerfile @@ -0,0 +1,3 @@ +FROM mongo +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/dist/nginx/conf.d/default.conf b/docker/dist/nginx/conf.d/default.conf new file mode 100755 index 000000000..0592dcd06 --- /dev/null +++ b/docker/dist/nginx/conf.d/default.conf @@ -0,0 +1,35 @@ +server { + listen 80; + listen [::]:80; + + server_name checkmate-demo.bluewavelabs.ca; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server:5000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api-docs/ { + proxy_pass http://server:5000/api-docs/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} \ No newline at end of file diff --git a/docker/dist/redis.Dockerfile b/docker/dist/redis.Dockerfile new file mode 100755 index 000000000..af68ec61e --- /dev/null +++ b/docker/dist/redis.Dockerfile @@ -0,0 +1,2 @@ +FROM redis +EXPOSE 6379 \ No newline at end of file diff --git a/docker/dist/server.Dockerfile b/docker/dist/server.Dockerfile new file mode 100755 index 000000000..202422dcc --- /dev/null +++ b/docker/dist/server.Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY ./server/package*.json ./ + +RUN npm install + +COPY ./server/ ./ + +EXPOSE 5000 + +CMD ["node", "index.js"] \ No newline at end of file diff --git a/docker/prod/build_images.sh b/docker/prod/build_images.sh new file mode 100755 index 000000000..23e91355c --- /dev/null +++ b/docker/prod/build_images.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Change directory to root directory for correct Docker Context +cd "$(dirname "$0")" +cd ../../ + +# Define an array of services and their Dockerfiles +declare -A services=( + ["uptime_client"]="./docker/prod/client.Dockerfile" + ["uptime_database_mongo"]="./docker/prod/mongoDB.Dockerfile" + ["uptime_redis"]="./docker/prod/redis.Dockerfile" + ["uptime_server"]="./docker/prod/server.Dockerfile" +) + +# Loop through each service and build the corresponding image +for service in "${!services[@]}"; do + docker build -f "${services[$service]}" -t "$service" . + + # Check if the build succeeded + if [ $? -ne 0 ]; then + echo "Error building $service image. Exiting..." + exit 1 + fi +done + +echo "All images built successfully" \ No newline at end of file diff --git a/docker/prod/certbot-compose.yaml b/docker/prod/certbot-compose.yaml new file mode 100755 index 000000000..9220d07a1 --- /dev/null +++ b/docker/prod/certbot-compose.yaml @@ -0,0 +1,19 @@ +version: "3" + +services: + webserver: + image: nginx:latest + ports: + - 80:80 + - 443:443 + restart: always + volumes: + - ./nginx/conf.d/:/etc/nginx/conf.d/:ro + - ./certbot/www/:/var/www/certbot/:ro + certbot: + image: certbot/certbot:latest + volumes: + - ./certbot/www/:/var/www/certbot/:rw + - ./certbot/conf/:/etc/letsencrypt/:rw + depends_on: + - webserver diff --git a/docker/prod/client.Dockerfile b/docker/prod/client.Dockerfile new file mode 100755 index 000000000..e81a1af26 --- /dev/null +++ b/docker/prod/client.Dockerfile @@ -0,0 +1,29 @@ +FROM node:20-alpine AS build + +ENV NODE_OPTIONS="--max-old-space-size=4096" + +WORKDIR /app + +RUN apk add --no-cache \ + python3 \ + make g++ \ + gcc \ + libc-dev \ + linux-headers \ + libusb-dev \ + eudev-dev + +COPY ./client/package*.json ./ + +RUN npm install + +COPY ./client . + +RUN npm run build + +FROM nginx:1.27.1-alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY --from=build /app/env.sh /docker-entrypoint.d/env.sh +RUN chmod +x /docker-entrypoint.d/env.sh +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/docker/prod/docker-compose.yaml b/docker/prod/docker-compose.yaml new file mode 100755 index 000000000..0da2da996 --- /dev/null +++ b/docker/prod/docker-compose.yaml @@ -0,0 +1,57 @@ +services: + client: + image: uptime_client:latest + restart: always + environment: + UPTIME_APP_API_BASE_URL: "https://checkmate-demo.bluewavelabs.ca/api/v1" + UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/" + ports: + - "80:80" + - "443:443" + depends_on: + - server + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d/:ro + - ./certbot/www:/var/www/certbot/:ro + - ./certbot/conf/:/etc/nginx/ssl/:ro + + certbot: + image: certbot/certbot:latest + restart: always + volumes: + - ./certbot/www/:/var/www/certbot/:rw + - ./certbot/conf/:/etc/letsencrypt/:rw + server: + image: uptime_server:latest + restart: always + ports: + - "5000:5000" + env_file: + - server.env + depends_on: + - redis + - mongodb + redis: + image: uptime_redis:latest + restart: always + ports: + - "6379:6379" + volumes: + - ./redis/data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 5s + mongodb: + image: uptime_database_mongo:latest + restart: always + command: ["mongod", "--quiet", "--auth"] + ports: + - "27017:27017" + volumes: + - ./mongo/data:/data/db + - ./mongo/init/create_users.js:/docker-entrypoint-initdb.d/create_users.js + env_file: + - mongo.env diff --git a/docker/prod/mongo/init/create_users.js b/docker/prod/mongo/init/create_users.js new file mode 100755 index 000000000..adb2bf078 --- /dev/null +++ b/docker/prod/mongo/init/create_users.js @@ -0,0 +1,16 @@ +var username = process.env.USERNAME_ENV_VAR; +var password = process.env.PASSWORD_ENV_VAR; + +db = db.getSiblingDB("uptime_db"); + +db.createUser({ + user: username, + pwd: password, + roles: [ + { + role: "readWrite", + db: "uptime_db", + }, + ], +}); +print("User uptime_user created successfully"); diff --git a/docker/prod/mongoDB.Dockerfile b/docker/prod/mongoDB.Dockerfile new file mode 100755 index 000000000..969a320c2 --- /dev/null +++ b/docker/prod/mongoDB.Dockerfile @@ -0,0 +1,3 @@ +FROM mongo +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/prod/nginx/conf.d/cerbot b/docker/prod/nginx/conf.d/cerbot new file mode 100755 index 000000000..f26cfeb23 --- /dev/null +++ b/docker/prod/nginx/conf.d/cerbot @@ -0,0 +1,15 @@ +server { + listen 80; + listen [::]:80; + + server_name checkmate-demo.bluewavelabs.ca www.checkmate-demo.bluewavelabs.ca; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://[domain-name]$request_uri; + } +} diff --git a/docker/prod/nginx/conf.d/default.conf b/docker/prod/nginx/conf.d/default.conf new file mode 100755 index 000000000..98e1bd97d --- /dev/null +++ b/docker/prod/nginx/conf.d/default.conf @@ -0,0 +1,69 @@ +server { + listen 80; + listen [::]:80; + + server_name checkmate-demo.bluewavelabs.ca; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server:5000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api-docs/ { + proxy_pass http://server:5000/api-docs/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 443 default_server ssl http2; + listen [::]:443 ssl http2; + + server_name checkmate-demo.bluewavelabs.ca; + + ssl_certificate /etc/nginx/ssl/live/checkmate-demo.bluewavelabs.ca/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/live/checkmate-demo.bluewavelabs.ca/privkey.pem; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server:5000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api-docs/ { + proxy_pass http://server:5000/api-docs/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/docker/prod/redis.Dockerfile b/docker/prod/redis.Dockerfile new file mode 100755 index 000000000..af68ec61e --- /dev/null +++ b/docker/prod/redis.Dockerfile @@ -0,0 +1,2 @@ +FROM redis +EXPOSE 6379 \ No newline at end of file diff --git a/docker/prod/server.Dockerfile b/docker/prod/server.Dockerfile new file mode 100755 index 000000000..998bbe4fe --- /dev/null +++ b/docker/prod/server.Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +ENV NODE_OPTIONS="--max-old-space-size=2048" + +WORKDIR /app + +COPY ./server/package*.json ./ + +RUN npm install + +COPY ./server ./ + +EXPOSE 5000 + +CMD ["node", "index.js"] \ No newline at end of file diff --git a/docker/staging/build_images.sh b/docker/staging/build_images.sh new file mode 100755 index 000000000..ccfd451ba --- /dev/null +++ b/docker/staging/build_images.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Change directory to root directory for correct Docker Context +cd "$(dirname "$0")" +cd ../../ + +# Define an array of services and their Dockerfiles +declare -A services=( + ["uptime_client"]="./docker/staging/client.Dockerfile" + ["uptime_database_mongo"]="./docker/staging/mongoDB.Dockerfile" + ["uptime_redis"]="./docker/staging/redis.Dockerfile" + ["uptime_server"]="./docker/staging/server.Dockerfile" +) + +# Loop through each service and build the corresponding image +for service in "${!services[@]}"; do + docker build -f "${services[$service]}" -t "$service" . + + # Check if the build succeeded + if [ $? -ne 0 ]; then + echo "Error building $service image. Exiting..." + exit 1 + fi +done + +echo "All images built successfully" \ No newline at end of file diff --git a/docker/staging/cerbot-compose.yaml b/docker/staging/cerbot-compose.yaml new file mode 100755 index 000000000..9220d07a1 --- /dev/null +++ b/docker/staging/cerbot-compose.yaml @@ -0,0 +1,19 @@ +version: "3" + +services: + webserver: + image: nginx:latest + ports: + - 80:80 + - 443:443 + restart: always + volumes: + - ./nginx/conf.d/:/etc/nginx/conf.d/:ro + - ./certbot/www/:/var/www/certbot/:ro + certbot: + image: certbot/certbot:latest + volumes: + - ./certbot/www/:/var/www/certbot/:rw + - ./certbot/conf/:/etc/letsencrypt/:rw + depends_on: + - webserver diff --git a/docker/staging/client.Dockerfile b/docker/staging/client.Dockerfile index 9eeedca5b..aa4d228fc 100644 --- a/docker/staging/client.Dockerfile +++ b/docker/staging/client.Dockerfile @@ -14,11 +14,11 @@ RUN apk add --no-cache \ eudev-dev -COPY package*.json ./ +COPY ./client/package*.json ./ RUN npm install -COPY . ./ +COPY ./client ./ RUN npm run build diff --git a/docker/staging/docker-compose.yaml b/docker/staging/docker-compose.yaml new file mode 100755 index 000000000..06a50079b --- /dev/null +++ b/docker/staging/docker-compose.yaml @@ -0,0 +1,59 @@ +services: + client: + image: ghcr.io/bluewave-labs/checkmate-frontend:staging + restart: always + environment: + UPTIME_APP_API_BASE_URL: "https://checkmate-test.bluewavelabs.ca/api/v1" + UPTIME_STATUS_PAGE_SUBDOMAIN_PREFIX: "http://uptimegenie.com/" + ports: + - "80:80" + - "443:443" + depends_on: + - server + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d/:ro + - ./certbot/www:/var/www/certbot/:ro + - ./certbot/conf/:/etc/nginx/ssl/:ro + + certbot: + image: certbot/certbot:latest + restart: always + volumes: + - ./certbot/www/:/var/www/certbot/:rw + - ./certbot/conf/:/etc/letsencrypt/:rw + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew --webroot -w /var/www/certbot; sleep 12h & wait $${!}; done;'" + + server: + image: ghcr.io/bluewave-labs/checkmate-backend:staging + restart: always + ports: + - "5000:5000" + env_file: + - server.env + depends_on: + - redis + - mongodb + redis: + image: ghcr.io/bluewave-labs/checkmate-redis:staging + restart: always + ports: + - "6379:6379" + volumes: + - ./redis/data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 5s + mongodb: + image: ghcr.io/bluewave-labs/checkmate-mongo:staging + restart: always + command: ["mongod", "--quiet", "--replSet", "rs0", "--bind_ip_all"] + ports: + - "27017:27017" + volumes: + - ./mongo/data:/data/db + - ./mongo/init/create_users.js:/docker-entrypoint-initdb.d/create_users.js + env_file: + - mongo.env diff --git a/docker/staging/mongo/init/create_users.js b/docker/staging/mongo/init/create_users.js new file mode 100755 index 000000000..adb2bf078 --- /dev/null +++ b/docker/staging/mongo/init/create_users.js @@ -0,0 +1,16 @@ +var username = process.env.USERNAME_ENV_VAR; +var password = process.env.PASSWORD_ENV_VAR; + +db = db.getSiblingDB("uptime_db"); + +db.createUser({ + user: username, + pwd: password, + roles: [ + { + role: "readWrite", + db: "uptime_db", + }, + ], +}); +print("User uptime_user created successfully"); diff --git a/docker/staging/mongoDB.Dockerfile b/docker/staging/mongoDB.Dockerfile new file mode 100755 index 000000000..969a320c2 --- /dev/null +++ b/docker/staging/mongoDB.Dockerfile @@ -0,0 +1,3 @@ +FROM mongo +EXPOSE 27017 +CMD ["mongod"] diff --git a/docker/staging/nginx/conf.d/certbot b/docker/staging/nginx/conf.d/certbot new file mode 100755 index 000000000..eba24638c --- /dev/null +++ b/docker/staging/nginx/conf.d/certbot @@ -0,0 +1,15 @@ +server { + listen 80; + listen [::]:80; + + server_name checkmate-test.bluewavelabs.ca www.checkmate-test.bluewavelabs.ca; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://[domain-name]$request_uri; + } +} diff --git a/docker/staging/nginx/conf.d/default.conf b/docker/staging/nginx/conf.d/default.conf new file mode 100755 index 000000000..4ea324b11 --- /dev/null +++ b/docker/staging/nginx/conf.d/default.conf @@ -0,0 +1,77 @@ +server { + listen 80; + listen [::]:80; + + server_name checkmate-test.bluewavelabs.ca; + server_tokens off; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server:5000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ''; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + } + + location /api-docs/ { + proxy_pass http://server:5000/api-docs/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + +server { + listen 443 default_server ssl http2; + listen [::]:443 ssl http2; + + server_name checkmate-test.bluewavelabs.ca; + + ssl_certificate /etc/nginx/ssl/live/checkmate-test.bluewavelabs.ca/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/live/checkmate-test.bluewavelabs.ca/privkey.pem; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://server:5000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ''; + chunked_transfer_encoding off; + proxy_buffering off; + proxy_cache off; + } + + location /api-docs/ { + proxy_pass http://server:5000/api-docs/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/docker/staging/redis.Dockerfile b/docker/staging/redis.Dockerfile new file mode 100755 index 000000000..af68ec61e --- /dev/null +++ b/docker/staging/redis.Dockerfile @@ -0,0 +1,2 @@ +FROM redis +EXPOSE 6379 \ No newline at end of file diff --git a/docker/staging/server.Dockerfile b/docker/staging/server.Dockerfile new file mode 100755 index 000000000..998bbe4fe --- /dev/null +++ b/docker/staging/server.Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +ENV NODE_OPTIONS="--max-old-space-size=2048" + +WORKDIR /app + +COPY ./server/package*.json ./ + +RUN npm install + +COPY ./server ./ + +EXPOSE 5000 + +CMD ["node", "index.js"] \ No newline at end of file diff --git a/env.sh b/env.sh deleted file mode 100755 index d95f8ee8f..000000000 --- a/env.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/sh -for i in $(env | grep UPTIME_APP_) -do - key=$(echo $i | cut -d '=' -f 1) - value=$(echo $i | cut -d '=' -f 2-) - echo $key=$value - find /usr/share/nginx/html -type f \( -name '*.js' -o -name '*.css' \) -exec sed -i "s|${key}|${value}|g" '{}' + -done \ No newline at end of file diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100755 index 000000000..d88f9054e --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1 @@ +./docker \ No newline at end of file diff --git a/server/.github/pull_request_template.md b/server/.github/pull_request_template.md new file mode 100755 index 000000000..ed81cc04b --- /dev/null +++ b/server/.github/pull_request_template.md @@ -0,0 +1,23 @@ +**(Please remove this line only before submitting your PR. Ensure that all relevant items are checked before submission.)** + +## Describe your changes + +Briefly describe the changes you made and their purpose. + +## Write your issue number after "Fixes " + +Fixes #123 + +## Please ensure all items are checked off before requesting a review. "Checked off" means you need to add an "x" character between brackets so they turn into checkmarks. + +- [ ] (Do not skip this or your PR will be closed) I deployed the application locally. +- [ ] (Do not skip this or your PR will be closed) I have performed a self-review and testing of my code. +- [ ] I have included the issue # in the PR. +- [ ] I have added i18n support to visible strings (instead of `
Add
`, use): +```Javascript +const { t } = useTranslation(); +
{t('add')}
+``` +- [ ] The issue I am working on is assigned to me. +- [ ] I didn't use any hardcoded values (otherwise it will not scale, and will make it difficult to maintain consistency across the application). +- [ ] My PR is granular and targeted to one specific feature. diff --git a/server/.github/workflows/staging-deploy.yml b/server/.github/workflows/staging-deploy.yml new file mode 100755 index 000000000..8fadf12d4 --- /dev/null +++ b/server/.github/workflows/staging-deploy.yml @@ -0,0 +1,68 @@ +name: Staging deploy + +on: + push: + branches: ["develop"] + +jobs: + docker-build-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Server Docker image + run: | + docker build \ + -t ghcr.io/bluewave-labs/checkmate-backend:staging \ + -f ./docker/staging/server.Dockerfile \ + --label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate-backend \ + . + + - name: Push Server Docker image + run: docker push ghcr.io/bluewave-labs/checkmate-backend:staging + + - name: Build Mongo Docker image + run: | + docker build \ + -t ghcr.io/bluewave-labs/checkmate-mongo:staging \ + -f ./docker/staging/mongoDB.Dockerfile \ + --label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate-backend \ + . + + - name: Push MongoDB Docker image + run: docker push ghcr.io/bluewave-labs/checkmate-mongo:staging + + - name: Build Redis Docker image + run: | + docker build \ + -t ghcr.io/bluewave-labs/checkmate-redis:staging \ + -f ./docker/staging/redis.Dockerfile \ + --label org.opencontainers.image.source=https://github.com/bluewave-labs/checkmate-backend \ + . + + - name: Push Redis Docker image + run: docker push ghcr.io/bluewave-labs/checkmate-redis:staging + + deploy-to-staging: + needs: docker-build-and-push + runs-on: ubuntu-latest + steps: + - name: SSH into server and restart container using Docker Compose + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.STAGING_SERVER_HOST }} + username: ${{ secrets.STAGING_SERVER_USER }} + key: ${{ secrets.STAGING_SERVER_SSH_KEY }} + script: | + cd checkmate/server/docker/staging + docker compose down + docker compose pull + docker compose up -d diff --git a/server/.gitignore b/server/.gitignore new file mode 100755 index 000000000..bab6fb27c --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,9 @@ +node_modules +.env +*.log +*.sh +.nyc_output +coverage +.clinic +node_modules +.vscode/* diff --git a/server/.mocharc.cjs b/server/.mocharc.cjs new file mode 100755 index 000000000..e4c8f0dd7 --- /dev/null +++ b/server/.mocharc.cjs @@ -0,0 +1,8 @@ +module.exports = { + require: ["esm", "chai/register-expect.js"], // Include Chai's "expect" interface globally + spec: "tests/**/*.test.js", // Specify test files + timeout: 5000, // Set test-case timeout in milliseconds + recursive: true, // Include subdirectories + reporter: "spec", // Use the "spec" reporter + exit: true, // Force Mocha to quit after tests complete +}; diff --git a/server/.nycrc b/server/.nycrc new file mode 100755 index 000000000..96f20d795 --- /dev/null +++ b/server/.nycrc @@ -0,0 +1,8 @@ +{ + "all": true, + "include": ["controllers/*.js", "utils/*.js", "service/*.js", "db/mongo/modules/*.js"], + "exclude": ["**/*.test.js"], + "reporter": ["html", "text", "lcov"], + "sourceMap": false, + "instrument": true +} diff --git a/server/.prettierrc b/server/.prettierrc new file mode 100755 index 000000000..5c345e37a --- /dev/null +++ b/server/.prettierrc @@ -0,0 +1,16 @@ +{ + "printWidth": 90, + "useTabs": true, + "tabWidth": 2, + "singleQuote": false, + "bracketSpacing": true, + "proseWrap": "preserve", + "bracketSameLine": false, + "singleAttributePerLine": true, + "semi": true, + "jsxSingleQuote": false, + "quoteProps": "as-needed", + "arrowParens": "always", + "trailingComma": "es5", + "htmlWhitespaceSensitivity": "css" +} diff --git a/server/LICENSE b/server/LICENSE new file mode 100755 index 000000000..4a43a1939 --- /dev/null +++ b/server/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) 2025 BlueWave Labs + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/server/README.md b/server/README.md new file mode 100755 index 000000000..912464d6c --- /dev/null +++ b/server/README.md @@ -0,0 +1,16 @@ +

Checkmate backend

+ +

The backend service for Checkmate, an open source uptime and infrastructure monitoring application

+ +This repository contains the **backend** of Checkmate, which handles data processing, storage, and API services for the Checkmate monitoring tool. The backend is responsible for managing uptime checks, handling real-time alerts, and storing historical monitoring data. It integrates seamlessly with the [Checkmate frontend](https://github.com/bluewave-labs/checkmate) and can be deployed independently. + +Checkmate's backend is designed to be lightweight and scalable, ensuring reliable performance even with a high number of active monitors. + +## Installation & documentation + +For setup instructions, configuration details, and deployment guidelines, please visit our official documentation at [docs.checkmate.so](https://docs.checkmate.so). + +## Related repositories + +- [Checkmate Frontend](https://github.com/bluewave-labs/checkmate) +- [Capture Agent](https://github.com/bluewave-labs/capture) (Optional, provides additional server insights) diff --git a/server/controllers/announcementsController.js b/server/controllers/announcementsController.js new file mode 100755 index 000000000..024ed95a5 --- /dev/null +++ b/server/controllers/announcementsController.js @@ -0,0 +1,79 @@ +import { createAnnouncementValidation } from "../validation/joi.js"; +import { handleError } from "./controllerUtils.js"; + +const SERVICE_NAME = "announcementController"; + +/** + * Controller for managing announcements in the system. + * This class handles the creation of new announcements. + * + * @class AnnouncementController + */ + +class AnnouncementController { + constructor(db, stringService) { + this.db = db; + this.stringService = stringService; + this.createAnnouncement = this.createAnnouncement.bind(this); + this.getAnnouncement = this.getAnnouncement.bind(this); + } + + /** + * Handles the creation of a new announcement. + * + * @async + * @param {Object} req - The request object, containing the announcement data in the body. + * @param {Object} res - The response object used to send the result back to the client. + * @param {Function} next - The next middleware function in the stack for error handling. + * + * @returns {Promise} A promise that resolves once the response is sent. + */ + createAnnouncement = async (req, res, next) => { + try { + await createAnnouncementValidation.validateAsync(req.body); + } catch (error) { + return next(handleError(error, SERVICE_NAME)); // Handle Joi validation errors + } + + const { title, message } = req.body; + + try { + const announcementData = { + title: title.trim(), + message: message.trim(), + userId: req.user._id, + }; + + const newAnnouncement = await this.db.createAnnouncement(announcementData); + return res.success({ + msg: this.stringService.createAnnouncement, + data: newAnnouncement, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "createAnnouncement")); + } + }; + + /** + * Handles retrieving announcements with pagination. + * + * @async + * @param {Object} res - The response object used to send the result back to the client. + * - `data`: The list of announcements to be sent back to the client. + * - `msg`: A message about the success of the request. + * @param {Function} next - The next middleware function in the stack for error handling. + */ + getAnnouncement = async (req, res, next) => { + try { + const allAnnouncements = await this.db.getAnnouncements(); + return res.success({ + msg: this.stringService.getAnnouncement, + data: allAnnouncements, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getAnnouncement")); + } + }; +} + +export default AnnouncementController; diff --git a/server/controllers/authController.js b/server/controllers/authController.js new file mode 100755 index 000000000..dc2a9c104 --- /dev/null +++ b/server/controllers/authController.js @@ -0,0 +1,452 @@ +import { + registrationBodyValidation, + loginValidation, + editUserParamValidation, + editUserBodyValidation, + recoveryValidation, + recoveryTokenValidation, + newPasswordValidation, +} from "../validation/joi.js"; +import logger from "../utils/logger.js"; +import jwt from "jsonwebtoken"; +import { getTokenFromHeaders } from "../utils/utils.js"; +import crypto from "crypto"; +import { handleValidationError, handleError } from "./controllerUtils.js"; +const SERVICE_NAME = "authController"; + +class AuthController { + constructor(db, settingsService, emailService, jobQueue, stringService) { + this.db = db; + this.settingsService = settingsService; + this.emailService = emailService; + this.jobQueue = jobQueue; + this.stringService = stringService; + } + + /** + * Creates and returns JWT token with an arbitrary payload + * @function + * @param {Object} payload + * @param {Object} appSettings + * @returns {String} + * @throws {Error} + */ + issueToken = (payload, appSettings) => { + try { + const tokenTTL = appSettings?.jwtTTL ?? "2h"; + const tokenSecret = appSettings?.jwtSecret; + const payloadData = payload; + + return jwt.sign(payloadData, tokenSecret, { expiresIn: tokenTTL }); + } catch (error) { + throw handleError(error, SERVICE_NAME, "issueToken"); + } + }; + + /** + * Registers a new user. If the user is the first account, a JWT secret is created. If not, an invite token is required. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.body - The body of the request. + * @property {string} req.body.inviteToken - The invite token for registration. + * @property {Object} req.file - The file object for the user's profile image. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the creation of the user, the created user data, and a JWT token. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + */ + registerUser = async (req, res, next) => { + try { + if(req.body?.email){ + req.body.email = req.body.email?.toLowerCase(); + } + await registrationBodyValidation.validateAsync(req.body); + } catch (error) { + const validationError = handleValidationError(error, SERVICE_NAME); + next(validationError); + return; + } + // Create a new user + try { + const { inviteToken } = req.body; + // If superAdmin exists, a token should be attached to all further register requests + const superAdminExists = await this.db.checkSuperadmin(req, res); + if (superAdminExists) { + await this.db.getInviteTokenAndDelete(inviteToken); + } else { + // This is the first account, create JWT secret to use if one is not supplied by env + const jwtSecret = crypto.randomBytes(64).toString("hex"); + await this.db.updateAppSettings({ jwtSecret }); + } + + const newUser = await this.db.insertUser({ ...req.body }, req.file); + logger.info({ + message: this.stringService.authCreateUser, + service: SERVICE_NAME, + details: newUser._id, + }); + + const userForToken = { ...newUser._doc }; + delete userForToken.profileImage; + delete userForToken.avatarImage; + + const appSettings = await this.settingsService.getSettings(); + + const token = this.issueToken(userForToken, appSettings); + + this.emailService + .buildAndSendEmail( + "welcomeEmailTemplate", + { name: newUser.firstName }, + newUser.email, + "Welcome to Uptime Monitor" + ) + .catch((error) => { + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "registerUser", + stack: error.stack, + }); + }); + + res.success({ + msg: this.stringService.authCreateUser, + data: { user: newUser, token: token }, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "registerController")); + } + }; + + /** + * Logs in a user by validating the user's credentials and issuing a JWT token. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.body - The body of the request. + * @property {string} req.body.email - The email of the user. + * @property {string} req.body.password - The password of the user. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the login of the user, the user data (without password and avatar image), and a JWT token. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422) or the password is incorrect. + */ + loginUser = async (req, res, next) => { + try { + if(req.body?.email){ + req.body.email = req.body.email?.toLowerCase(); + } + await loginValidation.validateAsync(req.body); + } catch (error) { + const validationError = handleValidationError(error, SERVICE_NAME); + next(validationError); + return; + } + try { + const { email, password } = req.body; + + // Check if user exists + const user = await this.db.getUserByEmail(email); + + // Compare password + const match = await user.comparePassword(password); + if (match !== true) { + const error = new Error(this.stringService.authIncorrectPassword); + error.status = 401; + next(error); + return; + } + + // Remove password from user object. Should this be abstracted to DB layer? + const userWithoutPassword = { ...user._doc }; + delete userWithoutPassword.password; + delete userWithoutPassword.avatarImage; + + // Happy path, return token + const appSettings = await this.settingsService.getSettings(); + const token = this.issueToken(userWithoutPassword, appSettings); + // reset avatar image + userWithoutPassword.avatarImage = user.avatarImage; + + return res.success({ + msg: this.stringService.authLoginUser, + data: { + user: userWithoutPassword, + token: token, + }, + }); + } catch (error) { + error.status = 401; + next(handleError(error, SERVICE_NAME, "loginUser")); + } + }; + + /** + * Edits a user's information. If the user wants to change their password, the current password is checked before updating to the new password. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.params - The parameters of the request. + * @property {string} req.params.userId - The ID of the user to be edited. + * @property {Object} req.body - The body of the request. + * @property {string} req.body.password - The current password of the user. + * @property {string} req.body.newPassword - The new password of the user. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the update of the user, and the updated user data. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422), the user is unauthorized (401), or the password is incorrect (403). + */ + editUser = async (req, res, next) => { + try { + await editUserParamValidation.validateAsync(req.params); + await editUserBodyValidation.validateAsync(req.body); + } catch (error) { + const validationError = handleValidationError(error, SERVICE_NAME); + next(validationError); + return; + } + + // TODO is this neccessary any longer? Verify ownership middleware should handle this + if (req.params.userId !== req.user._id.toString()) { + const error = new Error(this.stringService.unauthorized); + error.status = 401; + error.service = SERVICE_NAME; + next(error); + return; + } + + try { + // Change Password check + if (req.body.password && req.body.newPassword) { + // Get token from headers + const token = getTokenFromHeaders(req.headers); + // Get email from token + const { jwtSecret } = this.settingsService.getSettings(); + const { email } = jwt.verify(token, jwtSecret); + // Add user email to body for DB operation + req.body.email = email; + // Get user + const user = await this.db.getUserByEmail(email); + // Compare passwords + const match = await user.comparePassword(req.body.password); + // If not a match, throw a 403 + // 403 instead of 401 to avoid triggering axios interceptor + if (!match) { + const error = new Error(this.stringService.authIncorrectPassword); + error.status = 403; + next(error); + return; + } + // If a match, update the password + req.body.password = req.body.newPassword; + } + + const updatedUser = await this.db.updateUser(req, res); + res.success({ + msg: this.stringService.authUpdateUser, + data: updatedUser, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "userEditController")); + } + }; + + /** + * Checks if a superadmin account exists in the database. + * @async + * @param {Object} req - The Express request object. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the existence of a superadmin, and a boolean indicating the existence of a superadmin. + * @throws {Error} If there is an error during the process. + */ + checkSuperadminExists = async (req, res, next) => { + try { + const superAdminExists = await this.db.checkSuperadmin(req, res); + + return res.success({ + msg: this.stringService.authAdminExists, + data: superAdminExists, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "checkSuperadminController")); + } + }; + /** + * Requests a recovery token for a user. The user's email is validated and a recovery token is created and sent via email. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.body - The body of the request. + * @property {string} req.body.email - The email of the user requesting recovery. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the creation of the recovery token, and the message ID of the sent email. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + */ + requestRecovery = async (req, res, next) => { + try { + await recoveryValidation.validateAsync(req.body); + } catch (error) { + const validationError = handleValidationError(error, SERVICE_NAME); + next(validationError); + return; + } + + try { + const { email } = req.body; + const user = await this.db.getUserByEmail(email); + const recoveryToken = await this.db.requestRecoveryToken(req, res); + const name = user.firstName; + const { clientHost } = this.settingsService.getSettings(); + const url = `${clientHost}/set-new-password/${recoveryToken.token}`; + const msgId = await this.emailService.buildAndSendEmail( + "passwordResetTemplate", + { + name, + email, + url, + }, + email, + "Checkmate Password Reset" + ); + + return res.success({ + msg: this.stringService.authCreateRecoveryToken, + data: msgId, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "recoveryRequestController")); + } + }; + /** + * Validates a recovery token. The recovery token is validated and if valid, a success message is returned. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.body - The body of the request. + * @property {string} req.body.token - The recovery token to be validated. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status and a message indicating the validation of the recovery token. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + */ + validateRecovery = async (req, res, next) => { + try { + await recoveryTokenValidation.validateAsync(req.body); + } catch (error) { + const validationError = handleValidationError(error, SERVICE_NAME); + next(validationError); + return; + } + + try { + await this.db.validateRecoveryToken(req, res); + + return res.success({ + msg: this.stringService.authVerifyRecoveryToken, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "validateRecoveryTokenController")); + } + }; + + /** + * Resets a user's password. The new password is validated and if valid, the user's password is updated in the database and a new JWT token is issued. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.body - The body of the request. + * @property {string} req.body.token - The recovery token. + * @property {string} req.body.password - The new password of the user. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the reset of the password, the updated user data (without password and avatar image), and a new JWT token. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + */ + resetPassword = async (req, res, next) => { + try { + await newPasswordValidation.validateAsync(req.body); + } catch (error) { + const validationError = handleValidationError(error, SERVICE_NAME); + next(validationError); + return; + } + try { + const user = await this.db.resetPassword(req, res); + const appSettings = await this.settingsService.getSettings(); + const token = this.issueToken(user._doc, appSettings); + + return res.success({ + msg: this.stringService.authResetPassword, + data: { user, token }, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "resetPasswordController")); + } + }; + + /** + * Deletes a user and all associated monitors, checks, and alerts. + * + * @param {Object} req - The request object. + * @param {Object} res - The response object. + * @param {Function} next - The next middleware function. + * @returns {Object} The response object with success status and message. + * @throws {Error} If user validation fails or user is not found in the database. + */ + deleteUser = async (req, res, next) => { + try { + const token = getTokenFromHeaders(req.headers); + const decodedToken = jwt.decode(token); + const { email } = decodedToken; + + // Check if the user exists + const user = await this.db.getUserByEmail(email); + // 1. Find all the monitors associated with the team ID if superadmin + + const result = await this.db.getMonitorsByTeamId({ + params: { teamId: user.teamId }, + }); + + if (user.role.includes("superadmin")) { + //2. Remove all jobs, delete checks and alerts + result?.monitors.length > 0 && + (await Promise.all( + result.monitors.map(async (monitor) => { + await this.jobQueue.deleteJob(monitor); + await this.db.deleteChecks(monitor._id); + await this.db.deletePageSpeedChecksByMonitorId(monitor._id); + await this.db.deleteNotificationsByMonitorId(monitor._id); + }) + )); + + // 3. Delete team + await this.db.deleteTeam(user.teamId); + // 4. Delete all other team members + await this.db.deleteAllOtherUsers(); + // 5. Delete each monitor + await this.db.deleteMonitorsByUserId(user._id); + } + // 6. Delete the user by id + await this.db.deleteUser(user._id); + + return res.success({ + msg: this.stringService.authDeleteUser, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "deleteUserController")); + } + }; + + getAllUsers = async (req, res, next) => { + try { + const allUsers = await this.db.getAllUsers(req, res); + + return res.success({ + msg: this.stringService.authGetAllUsers, + data: allUsers, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getAllUsersController")); + } + }; +} + +export default AuthController; diff --git a/server/controllers/checkController.js b/server/controllers/checkController.js new file mode 100755 index 000000000..2addd7359 --- /dev/null +++ b/server/controllers/checkController.js @@ -0,0 +1,154 @@ +import { + createCheckParamValidation, + createCheckBodyValidation, + getChecksParamValidation, + getChecksQueryValidation, + getTeamChecksParamValidation, + getTeamChecksQueryValidation, + deleteChecksParamValidation, + deleteChecksByTeamIdParamValidation, + updateChecksTTLBodyValidation, +} from "../validation/joi.js"; +import jwt from "jsonwebtoken"; +import { getTokenFromHeaders } from "../utils/utils.js"; +import { handleValidationError, handleError } from "./controllerUtils.js"; + +const SERVICE_NAME = "checkController"; + +class CheckController { + constructor(db, settingsService, stringService) { + this.db = db; + this.settingsService = settingsService; + this.stringService = stringService; + } + + createCheck = async (req, res, next) => { + try { + await createCheckParamValidation.validateAsync(req.params); + await createCheckBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const checkData = { ...req.body }; + const check = await this.db.createCheck(checkData); + + return res.success({ + msg: this.stringService.checkCreate, + data: check, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "createCheck")); + } + }; + + getChecksByMonitor = async (req, res, next) => { + try { + await getChecksParamValidation.validateAsync(req.params); + await getChecksQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const result = await this.db.getChecksByMonitor(req); + + return res.success({ + msg: this.stringService.checkGet, + data: result, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getChecks")); + } + }; + + getChecksByTeam = async (req, res, next) => { + try { + await getTeamChecksParamValidation.validateAsync(req.params); + await getTeamChecksQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + try { + const checkData = await this.db.getChecksByTeam(req); + + return res.success({ + msg: this.stringService.checkGet, + data: checkData, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getTeamChecks")); + } + }; + + deleteChecks = async (req, res, next) => { + try { + await deleteChecksParamValidation.validateAsync(req.params); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const deletedCount = await this.db.deleteChecks(req.params.monitorId); + + return res.success({ + msg: this.stringService.checkDelete, + data: { deletedCount }, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "deleteChecks")); + } + }; + + deleteChecksByTeamId = async (req, res, next) => { + try { + await deleteChecksByTeamIdParamValidation.validateAsync(req.params); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const deletedCount = await this.db.deleteChecksByTeamId(req.params.teamId); + + return res.success({ + msg: this.stringService.checkDelete, + data: { deletedCount }, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "deleteChecksByTeamId")); + } + }; + + updateChecksTTL = async (req, res, next) => { + const SECONDS_PER_DAY = 86400; + + try { + await updateChecksTTLBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + // Get user's teamId + const token = getTokenFromHeaders(req.headers); + const { jwtSecret } = this.settingsService.getSettings(); + const { teamId } = jwt.verify(token, jwtSecret); + const ttl = parseInt(req.body.ttl, 10) * SECONDS_PER_DAY; + await this.db.updateChecksTTL(teamId, ttl); + + return res.success({ + msg: this.stringService.checkUpdateTTL, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "updateTTL")); + } + }; +} +export default CheckController; diff --git a/server/controllers/controllerUtils.js b/server/controllers/controllerUtils.js new file mode 100755 index 000000000..ec7f1122f --- /dev/null +++ b/server/controllers/controllerUtils.js @@ -0,0 +1,26 @@ +const handleValidationError = (error, serviceName) => { + error.status = 422; + error.service = serviceName; + error.message = error.details?.[0]?.message || error.message || "Validation Error"; + return error; +}; + +const handleError = (error, serviceName, method, status = 500) => { + error.status === undefined ? (error.status = status) : null; + error.service === undefined ? (error.service = serviceName) : null; + error.method === undefined ? (error.method = method) : null; + return error; +}; + +const fetchMonitorCertificate = async (sslChecker, monitor) => { + const monitorUrl = new URL(monitor.url); + const hostname = monitorUrl.hostname; + const cert = await sslChecker(hostname); + // Throw an error if no cert or if cert.validTo is not present + if (cert?.validTo === null || cert?.validTo === undefined) { + throw new Error("Certificate not found"); + } + return cert; +}; + +export { handleValidationError, handleError, fetchMonitorCertificate }; diff --git a/server/controllers/diagnosticController.js b/server/controllers/diagnosticController.js new file mode 100755 index 000000000..c46ac5ccb --- /dev/null +++ b/server/controllers/diagnosticController.js @@ -0,0 +1,62 @@ +import { handleError } from "./controllerUtils.js"; +const SERVICE_NAME = "diagnosticController"; + +class DiagnosticController { + constructor(db) { + this.db = db; + this.getDistributedUptimeDbExecutionStats = + this.getDistributedUptimeDbExecutionStats.bind(this); + this.getMonitorsByTeamIdExecutionStats = + this.getMonitorsByTeamIdExecutionStats.bind(this); + this.getDbStats = this.getDbStats.bind(this); + } + + async getDistributedUptimeDbExecutionStats(req, res, next) { + try { + const data = await this.db.getDistributedUptimeDbExecutionStats(req); + return res.success({ + msg: "OK", + data, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getDbExecutionStats")); + } + } + + async getMonitorsByTeamIdExecutionStats(req, res, next) { + try { + const data = await this.db.getMonitorsByTeamIdExecutionStats(req); + return res.success({ + msg: "OK", + data, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMonitorsByTeamIdExecutionStats")); + } + } + + async getDbStats(req, res, next) { + try { + const { methodName, args = [] } = req.body; + if (!methodName || !this.db[methodName]) { + return res.error({ + msg: "Invalid method name or method doesn't exist", + status: 400, + }); + } + const explainMethod = await this.db[methodName].apply(this.db, args); + const stats = { + methodName, + timestamp: new Date(), + explain: explainMethod, + }; + return res.success({ + msg: "OK", + data: stats, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getDbStats")); + } + } +} +export default DiagnosticController; diff --git a/server/controllers/distributedUptimeController.js b/server/controllers/distributedUptimeController.js new file mode 100755 index 000000000..c036e97bc --- /dev/null +++ b/server/controllers/distributedUptimeController.js @@ -0,0 +1,339 @@ +import { handleError } from "./controllerUtils.js"; +import Monitor from "../db/models/Monitor.js"; +import DistributedUptimeCheck from "../db/models/DistributedUptimeCheck.js"; +const SERVICE_NAME = "DistributedUptimeQueueController"; + +class DistributedUptimeController { + constructor({ db, http, statusService, logger }) { + this.db = db; + this.http = http; + this.statusService = statusService; + this.logger = logger; + this.resultsCallback = this.resultsCallback.bind(this); + this.getDistributedUptimeMonitors = this.getDistributedUptimeMonitors.bind(this); + this.subscribeToDistributedUptimeMonitors = + this.subscribeToDistributedUptimeMonitors.bind(this); + + this.subscribeToDistributedUptimeMonitorDetails = + this.subscribeToDistributedUptimeMonitorDetails.bind(this); + this.getDistributedUptimeMonitorDetails = + this.getDistributedUptimeMonitorDetails.bind(this); + } + + async resultsCallback(req, res, next) { + try { + const { id, result } = req.body; + // Calculate response time + const { + first_byte_took, + body_read_took, + dns_took, + conn_took, + connect_took, + tls_took, + status_code, + error, + upt_burnt, + } = result; + + // Calculate response time + const responseTime = first_byte_took / 1_000_000; + if (!isFinite(responseTime) || responseTime <= 0 || responseTime > 30000) { + this.logger.info({ + message: `Unreasonable response time detected: ${responseTime}ms from first_byte_took: ${first_byte_took}ns`, + service: SERVICE_NAME, + method: "resultsCallback", + }); + return; + } + + // Calculate if server is up or down + const isErrorStatus = status_code >= 400; + const hasError = error !== ""; + + const status = isErrorStatus || hasError ? false : true; + + // Build response + const distributedUptimeResponse = { + monitorId: id, + type: "distributed_http", + payload: result, + status, + code: status_code, + responseTime, + first_byte_took, + body_read_took, + dns_took, + conn_took, + connect_took, + tls_took, + upt_burnt, + }; + if (error) { + const code = status_code || this.NETWORK_ERROR; + distributedUptimeResponse.code = code; + distributedUptimeResponse.message = + this.http.STATUS_CODES[code] || "Network Error"; + } else { + distributedUptimeResponse.message = this.http.STATUS_CODES[status_code]; + } + + await this.statusService.updateStatus(distributedUptimeResponse); + + res.status(200).json({ message: "OK" }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "resultsCallback")); + } + } + + async getDistributedUptimeMonitors(req, res, next) { + try { + const monitors = await this.db.getMonitorsWithChecksByTeamId(req); + return res.success({ + msg: "OK", + data: monitors, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getDistributedUptimeMonitors")); + } + } + + async subscribeToDistributedUptimeMonitors(req, res, next) { + try { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("Access-Control-Allow-Origin", "*"); + // Disable compression + req.headers["accept-encoding"] = "identity"; + res.removeHeader("Content-Encoding"); + const BATCH_DELAY = 1000; + let batchTimeout = null; + let opInProgress = false; + let monitorStream = null; + let checksStream = null; + + // Do things here + const notifyChange = async () => { + if (opInProgress) { + // Get data + const { count, monitors } = await this.db.getMonitorsWithChecksByTeamId(req); + res.write(`data: ${JSON.stringify({ count, monitors })}\n\n`); + opInProgress = false; + } + batchTimeout = null; + }; + + const handleChange = () => { + opInProgress = true; + if (batchTimeout) clearTimeout(batchTimeout); + batchTimeout = setTimeout(notifyChange, BATCH_DELAY); + }; + + const createMonitorStream = () => { + if (monitorStream) { + try { + monitorStream.close(); + } catch (error) { + this.logger.error({ + message: "Error closing monitor stream", + service: SERVICE_NAME, + method: "subscribeToDistributedUptimeMonitors", + stack: error.stack, + }); + } + } + monitorStream = Monitor.watch( + [{ $match: { operationType: { $in: ["insert", "update", "delete"] } } }], + { fullDocument: "updateLookup" } + ); + + monitorStream.on("change", handleChange); + monitorStream.on("error", (error) => { + this.logger.error({ + message: "Error in monitor stream", + service: SERVICE_NAME, + method: "subscribeToDistributedUptimeMonitors", + stack: error.stack, + }); + createMonitorStream(); + }); + monitorStream.on("close", () => { + monitorStream = null; + }); + }; + + const createChecksStream = () => { + if (checksStream) { + try { + checksStream.close(); + } catch (error) { + this.logger.error({ + message: "Error closing checks stream", + service: SERVICE_NAME, + method: "subscribeToDistributedUptimeMonitors", + details: error, + }); + } + } + checksStream = DistributedUptimeCheck.watch( + [{ $match: { operationType: { $in: ["insert", "update", "delete"] } } }], + { fullDocument: "updateLookup" } + ); + checksStream.on("change", handleChange); + checksStream.on("error", (error) => { + this.logger.error({ + message: "Error in checks stream", + service: SERVICE_NAME, + method: "subscribeToDistributedUptimeMonitors", + stack: error.stack, + }); + createChecksStream(); + }); + checksStream.on("close", () => { + checksStream = null; + }); + }; + + createMonitorStream(); + createChecksStream(); + + req.on("close", () => { + if (batchTimeout) { + clearTimeout(batchTimeout); + } + monitorStream.close(); + checksStream.close(); + clearInterval(keepAlive); + }); + + // Keep connection alive + const keepAlive = setInterval(() => { + res.write(": keepalive\n\n"); + }, 10000); + + // Clean up on close + req.on("close", () => { + clearInterval(keepAlive); + }); + } catch (error) { + this.logger.error({ + message: "Error in subscribeToDistributedUptimeMonitors", + service: SERVICE_NAME, + method: "subscribeToDistributedUptimeMonitors", + stack: error.stack, + }); + next(handleError(error, SERVICE_NAME, "subscribeToDistributedUptimeMonitors")); + } + } + + async getDistributedUptimeMonitorDetails(req, res, next) { + try { + const monitor = await this.db.getDistributedUptimeDetailsById(req); + return res.success({ + msg: "OK", + data: monitor, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getDistributedUptimeMonitorDetails")); + } + } + + async subscribeToDistributedUptimeMonitorDetails(req, res, next) { + try { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("Access-Control-Allow-Origin", "*"); + + // disable compression + req.headers["accept-encoding"] = "identity"; + res.removeHeader("Content-Encoding"); + + const BATCH_DELAY = 1000; + let batchTimeout = null; + let opInProgress = false; + let checksStream = null; + // Do things here + const notifyChange = async () => { + try { + if (opInProgress) { + // Get data + const monitor = await this.db.getDistributedUptimeDetailsById(req); + res.write(`data: ${JSON.stringify({ monitor })}\n\n`); + opInProgress = false; + } + batchTimeout = null; + } catch (error) { + opInProgress = false; + batchTimeout = null; + this.logger.error({ + message: "Error in notifyChange", + service: SERVICE_NAME, + method: "subscribeToDistributedUptimeMonitorDetails", + stack: error.stack, + }); + next(handleError(error, SERVICE_NAME, "getDistributedUptimeMonitorDetails")); + } + }; + + const handleChange = () => { + opInProgress = true; + if (batchTimeout) clearTimeout(batchTimeout); + batchTimeout = setTimeout(notifyChange, BATCH_DELAY); + }; + + const createCheckStream = () => { + if (checksStream) { + try { + checksStream.close(); + } catch (error) { + this.logger.error({ + message: "Error closing checks stream", + service: SERVICE_NAME, + method: "subscribeToDistributedUptimeMonitorDetails", + stack: error.stack, + }); + } + } + checksStream = DistributedUptimeCheck.watch( + [{ $match: { operationType: { $in: ["insert", "update", "delete"] } } }], + { fullDocument: "updateLookup" } + ); + + checksStream.on("change", handleChange); + checksStream.on("error", (error) => { + this.logger.error({ + message: "Error in checks stream", + service: SERVICE_NAME, + method: "subscribeToDistributedUptimeMonitorDetails", + stack: error.stack, + }); + createCheckStream(); + }); + checksStream.on("close", () => { + checksStream = null; + }); + }; + + createCheckStream(); + + // Handle client disconnect + req.on("close", () => { + if (batchTimeout) { + clearTimeout(batchTimeout); + } + checksStream.close(); + clearInterval(keepAlive); + }); + + // Keep connection alive + const keepAlive = setInterval(() => { + res.write(": keepalive\n\n"); + }, 10000); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getDistributedUptimeMonitorDetails")); + } + } +} +export default DistributedUptimeController; diff --git a/server/controllers/inviteController.js b/server/controllers/inviteController.js new file mode 100755 index 000000000..bce40dfa1 --- /dev/null +++ b/server/controllers/inviteController.js @@ -0,0 +1,123 @@ +import { + inviteRoleValidation, + inviteBodyValidation, + inviteVerificationBodyValidation, +} from "../validation/joi.js"; +import logger from "../utils/logger.js"; +import jwt from "jsonwebtoken"; +import { handleError, handleValidationError } from "./controllerUtils.js"; +import { getTokenFromHeaders } from "../utils/utils.js"; + +const SERVICE_NAME = "inviteController"; + +class InviteController { + constructor(db, settingsService, emailService, stringService) { + this.db = db; + this.settingsService = settingsService; + this.emailService = emailService; + this.stringService = stringService; + } + + /** + * Issues an invitation to a new user. Only admins can invite new users. An invitation token is created and sent via email. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.headers - The headers of the request. + * @property {string} req.headers.authorization - The authorization header containing the JWT token. + * @property {Object} req.body - The body of the request. + * @property {string} req.body.email - The email of the user to be invited. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the sending of the invitation, and the invitation token. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + */ + getInviteToken = async (req, res, next) => { + try { + // Only admins can invite + const token = getTokenFromHeaders(req.headers); + const { role, teamId } = jwt.decode(token); + req.body.teamId = teamId; + try { + await inviteRoleValidation.validateAsync({ roles: role }); + await inviteBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + const inviteToken = await this.db.requestInviteToken({ ...req.body }); + return res.success({ + msg: this.stringService.inviteIssued, + data: inviteToken, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "inviteController")); + } + }; + + sendInviteEmail = async (req, res, next) => { + try { + // Only admins can invite + const token = getTokenFromHeaders(req.headers); + const { role, firstname, teamId } = jwt.decode(token); + req.body.teamId = teamId; + try { + await inviteRoleValidation.validateAsync({ roles: role }); + await inviteBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + const inviteToken = await this.db.requestInviteToken({ ...req.body }); + const { clientHost } = this.settingsService.getSettings(); + this.emailService + .buildAndSendEmail( + "employeeActivationTemplate", + { + name: firstname, + link: `${clientHost}/register/${inviteToken.token}`, + }, + req.body.email, + "Welcome to Uptime Monitor" + ) + .catch((error) => { + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "issueInvitation", + stack: error.stack, + }); + }); + + return res.success({ + msg: this.stringService.inviteIssued, + data: inviteToken, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "inviteController")); + } + }; + + inviteVerifyController = async (req, res, next) => { + try { + await inviteVerificationBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const invite = await this.db.getInviteToken(req.body.token); + + return res.success({ + msg: this.stringService.inviteVerified, + data: invite, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "inviteVerifyController")); + } + }; +} + +export default InviteController; diff --git a/server/controllers/maintenanceWindowController.js b/server/controllers/maintenanceWindowController.js new file mode 100755 index 000000000..a11f957ed --- /dev/null +++ b/server/controllers/maintenanceWindowController.js @@ -0,0 +1,163 @@ +import { + createMaintenanceWindowBodyValidation, + editMaintenanceWindowByIdParamValidation, + editMaintenanceByIdWindowBodyValidation, + getMaintenanceWindowByIdParamValidation, + getMaintenanceWindowsByMonitorIdParamValidation, + getMaintenanceWindowsByTeamIdQueryValidation, + deleteMaintenanceWindowByIdParamValidation, +} from "../validation/joi.js"; +import jwt from "jsonwebtoken"; +import { getTokenFromHeaders } from "../utils/utils.js"; +import { handleValidationError, handleError } from "./controllerUtils.js"; + +const SERVICE_NAME = "maintenanceWindowController"; + +class MaintenanceWindowController { + constructor(db, settingsService, stringService) { + this.db = db; + this.settingsService = settingsService; + this.stringService = stringService; + } + + createMaintenanceWindows = async (req, res, next) => { + try { + await createMaintenanceWindowBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + try { + const token = getTokenFromHeaders(req.headers); + const { jwtSecret } = this.settingsService.getSettings(); + const { teamId } = jwt.verify(token, jwtSecret); + const monitorIds = req.body.monitors; + const dbTransactions = monitorIds.map((monitorId) => { + return this.db.createMaintenanceWindow({ + teamId, + monitorId, + name: req.body.name, + active: req.body.active ? req.body.active : true, + repeat: req.body.repeat, + start: req.body.start, + end: req.body.end, + }); + }); + await Promise.all(dbTransactions); + + return res.success({ + msg: this.stringService.maintenanceWindowCreate, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "createMaintenanceWindow")); + } + }; + + getMaintenanceWindowById = async (req, res, next) => { + try { + await getMaintenanceWindowByIdParamValidation.validateAsync(req.params); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + try { + const maintenanceWindow = await this.db.getMaintenanceWindowById(req.params.id); + + return res.success({ + msg: this.stringService.maintenanceWindowGetById, + data: maintenanceWindow, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMaintenanceWindowById")); + } + }; + + getMaintenanceWindowsByTeamId = async (req, res, next) => { + try { + await getMaintenanceWindowsByTeamIdQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const token = getTokenFromHeaders(req.headers); + const { jwtSecret } = this.settingsService.getSettings(); + const { teamId } = jwt.verify(token, jwtSecret); + const maintenanceWindows = await this.db.getMaintenanceWindowsByTeamId( + teamId, + req.query + ); + + return res.success({ + msg: this.stringService.maintenanceWindowGetByTeam, + data: maintenanceWindows, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMaintenanceWindowsByUserId")); + } + }; + + getMaintenanceWindowsByMonitorId = async (req, res, next) => { + try { + await getMaintenanceWindowsByMonitorIdParamValidation.validateAsync(req.params); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId( + req.params.monitorId + ); + + return res.success({ + msg: this.stringService.maintenanceWindowGetByUser, + data: maintenanceWindows, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMaintenanceWindowsByMonitorId")); + } + }; + + deleteMaintenanceWindow = async (req, res, next) => { + try { + await deleteMaintenanceWindowByIdParamValidation.validateAsync(req.params); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + try { + await this.db.deleteMaintenanceWindowById(req.params.id); + return res.success({ + msg: this.stringService.maintenanceWindowDelete, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "deleteMaintenanceWindow")); + } + }; + + editMaintenanceWindow = async (req, res, next) => { + try { + await editMaintenanceWindowByIdParamValidation.validateAsync(req.params); + await editMaintenanceByIdWindowBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + try { + const editedMaintenanceWindow = await this.db.editMaintenanceWindowById( + req.params.id, + req.body + ); + return res.success({ + msg: this.stringService.maintenanceWindowEdit, + data: editedMaintenanceWindow, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "editMaintenanceWindow")); + } + }; +} + +export default MaintenanceWindowController; diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js new file mode 100755 index 000000000..2cd2d9678 --- /dev/null +++ b/server/controllers/monitorController.js @@ -0,0 +1,650 @@ +import Monitor from "../db/models/Monitor.js"; +import { + getMonitorByIdParamValidation, + getMonitorByIdQueryValidation, + getMonitorsByTeamIdParamValidation, + getMonitorsByTeamIdQueryValidation, + createMonitorBodyValidation, + createMonitorsBodyValidation, + getMonitorURLByQueryValidation, + editMonitorBodyValidation, + pauseMonitorParamValidation, + getMonitorStatsByIdParamValidation, + getMonitorStatsByIdQueryValidation, + getCertificateParamValidation, + getHardwareDetailsByIdParamValidation, + getHardwareDetailsByIdQueryValidation, +} from "../validation/joi.js"; +import sslChecker from "ssl-checker"; +import jwt from "jsonwebtoken"; +import { getTokenFromHeaders } from "../utils/utils.js"; +import logger from "../utils/logger.js"; +import { handleError, handleValidationError } from "./controllerUtils.js"; +import axios from "axios"; +import seedDb from "../db/mongo/utils/seedDb.js"; +import { seedDistributedTest } from "../db/mongo/utils/seedDb.js"; +const SERVICE_NAME = "monitorController"; + +class MonitorController { + constructor(db, settingsService, jobQueue, stringService) { + this.db = db; + this.settingsService = settingsService; + this.jobQueue = jobQueue; + this.stringService = stringService; + } + + /** + * Returns all monitors + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @param {function} next + * @returns {Promise} + * @throws {Error} + */ + getAllMonitors = async (req, res, next) => { + try { + const monitors = await this.db.getAllMonitors(); + return res.success({ + msg: this.stringService.monitorGetAll, + data: monitors, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getAllMonitors")); + } + }; + + /** + * Returns all monitors with uptime stats for 1,7,30, and 90 days + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @param {function} next + * @returns {Promise} + * @throws {Error} + */ + getAllMonitorsWithUptimeStats = async (req, res, next) => { + try { + const monitors = await this.db.getAllMonitorsWithUptimeStats(); + return res.success({ + msg: this.stringService.monitorGetAll, + data: monitors, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getAllMonitorsWithUptimeStats")); + } + }; + + getUptimeDetailsById = async (req, res, next) => { + try { + const data = await this.db.getUptimeDetailsById(req); + return res.success({ + msg: this.stringService.monitorGetByIdSuccess, + data, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMonitorDetailsById")); + } + }; + + /** + * Returns monitor stats for monitor with matching ID + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @param {function} next + * @returns {Promise} + * @throws {Error} + */ + getMonitorStatsById = async (req, res, next) => { + try { + await getMonitorStatsByIdParamValidation.validateAsync(req.params); + await getMonitorStatsByIdQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const monitorStats = await this.db.getMonitorStatsById(req); + return res.success({ + msg: this.stringService.monitorStatsById, + data: monitorStats, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMonitorStatsById")); + } + }; + + /** + * Get hardware details for a specific monitor by ID + * @async + * @param {Express.Request} req - Express request object containing monitorId in params + * @param {Express.Response} res - Express response object + * @param {Express.NextFunction} next - Express next middleware function + * @returns {Promise} + * @throws {Error} - Throws error if monitor not found or other database errors + */ + getHardwareDetailsById = async (req, res, next) => { + try { + await getHardwareDetailsByIdParamValidation.validateAsync(req.params); + await getHardwareDetailsByIdQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + try { + const monitor = await this.db.getHardwareDetailsById(req); + return res.success({ + msg: this.stringService.monitorGetByIdSuccess, + data: monitor, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getHardwareDetailsById")); + } + }; + + getMonitorCertificate = async (req, res, next, fetchMonitorCertificate) => { + try { + await getCertificateParamValidation.validateAsync(req.params); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + } + + try { + const { monitorId } = req.params; + const monitor = await this.db.getMonitorById(monitorId); + const certificate = await fetchMonitorCertificate(sslChecker, monitor); + + return res.success({ + msg: this.stringService.monitorCertificate, + data: { + certificateDate: new Date(certificate.validTo), + }, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMonitorCertificate")); + } + }; + + /** + * Retrieves a monitor by its ID. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.params - The parameters of the request. + * @property {string} req.params.monitorId - The ID of the monitor to be retrieved. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message, and the retrieved monitor data. + * @throws {Error} If there is an error during the process, especially if the monitor is not found (404) or if there is a validation error (422). + */ + getMonitorById = async (req, res, next) => { + try { + await getMonitorByIdParamValidation.validateAsync(req.params); + await getMonitorByIdQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const monitor = await this.db.getMonitorById(req.params.monitorId); + return res.success({ + msg: this.stringService.monitorGetByIdSuccess, + data: monitor, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMonitorById")); + } + }; + + /** + * Creates a new monitor and adds it to the job queue. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.body - The body of the request. + * @property {Array} req.body.notifications - The notifications associated with the monitor. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the creation of the monitor, and the created monitor data. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + */ + createMonitor = async (req, res, next) => { + try { + await createMonitorBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const notifications = req.body.notifications; + const monitor = await this.db.createMonitor(req, res); + + if (notifications && notifications.length > 0) { + monitor.notifications = await Promise.all( + notifications.map(async (notification) => { + notification.monitorId = monitor._id; + return await this.db.createNotification(notification); + }) + ); + } + + await monitor.save(); + // Add monitor to job queue + this.jobQueue.addJob(monitor._id, monitor); + return res.success({ + msg: this.stringService.monitorCreate, + data: monitor, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "createMonitor")); + } + }; + + /** + * Creates bulk monitors and adds them to the job queue. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.body - The body of the request. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the creation of the monitor, and the created monitor data. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + */ + createBulkMonitors = async (req, res, next) => { + try { + await createMonitorsBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + // create monitors + const monitors = await this.db.createBulkMonitors(req); + + // create notifications for each monitor + await Promise.all( + monitors.map(async (monitor, index) => { + const notifications = req.body[index].notifications; + + if (notifications?.length) { + monitor.notifications = await Promise.all( + notifications.map(async (notification) => { + notification.monitorId = monitor._id; + return await this.db.createNotification(notification); + }) + ); + await monitor.save(); + } + + // Add monitor to job queue + this.jobQueue.addJob(monitor._id, monitor); + }) + ); + + return res.success({ + msg: this.stringService.bulkMonitorsCreate, + data: monitors, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "createBulkMonitors")); + } + }; + + /** + * Checks if the endpoint can be resolved + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.query - The query parameters of the request. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message, and the resolution result. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + */ + checkEndpointResolution = async (req, res, next) => { + try { + await getMonitorURLByQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const { monitorURL } = req.query; + const parsedUrl = new URL(monitorURL); + const response = await axios.get(parsedUrl, { + timeout: 5000, + validateStatus: () => true, + }); + return res.success({ + status: response.status, + msg: response.statusText, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "checkEndpointResolution")); + } + }; + + /** + * Deletes a monitor by its ID and also deletes associated checks, alerts, and notifications. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.params - The parameters of the request. + * @property {string} req.params.monitorId - The ID of the monitor to be deleted. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status and a message indicating the deletion of the monitor. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422) or an error in deleting associated records. + */ + deleteMonitor = async (req, res, next) => { + try { + await getMonitorByIdParamValidation.validateAsync(req.params); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const monitor = await this.db.deleteMonitor(req, res, next); + // Delete associated checks,alerts,and notifications + + try { + const operations = [ + { name: "deleteJob", fn: () => this.jobQueue.deleteJob(monitor) }, + { name: "deleteChecks", fn: () => this.db.deleteChecks(monitor._id) }, + { + name: "deletePageSpeedChecks", + fn: () => this.db.deletePageSpeedChecksByMonitorId(monitor._id), + }, + { + name: "deleteNotifications", + fn: () => this.db.deleteNotificationsByMonitorId(monitor._id), + }, + { + name: "deleteHardwareChecks", + fn: () => this.db.deleteHardwareChecksByMonitorId(monitor._id), + }, + { + name: "deleteDistributedUptimeChecks", + fn: () => this.db.deleteDistributedChecksByMonitorId(monitor._id), + }, + + // TODO We don't actually want to delete the status page if there are other monitors in it + // We actually just want to remove the monitor being deleted from the status page. + // Only delete he status page if there are no other monitors in it. + { + name: "deleteStatusPages", + fn: () => this.db.deleteStatusPagesByMonitorId(monitor._id), + }, + ]; + const results = await Promise.allSettled(operations.map((op) => op.fn())); + + results.forEach((result, index) => { + if (result.status === "rejected") { + const operationName = operations[index].name; + logger.error({ + message: `Failed to ${operationName} for monitor ${monitor._id}`, + service: SERVICE_NAME, + method: "deleteMonitor", + stack: result.reason.stack, + }); + } + }); + } catch (error) { + logger.error({ + message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, + service: SERVICE_NAME, + method: "deleteMonitor", + stack: error.stack, + }); + } + return res.success({ msg: this.stringService.monitorDelete }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "deleteMonitor")); + } + }; + + /** + * Deletes all monitors associated with a team. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.headers - The headers of the request. + * @property {string} req.headers.authorization - The authorization header containing the JWT token. + * @param {Object} res - The Express response object. + * @param {function} next + * @returns {Object} The response object with a success status and a message indicating the number of deleted monitors. + * @throws {Error} If there is an error during the deletion process. + */ + deleteAllMonitors = async (req, res, next) => { + try { + const token = getTokenFromHeaders(req.headers); + const { jwtSecret } = this.settingsService.getSettings(); + const { teamId } = jwt.verify(token, jwtSecret); + const { monitors, deletedCount } = await this.db.deleteAllMonitors(teamId); + await Promise.all( + monitors.map(async (monitor) => { + try { + await this.jobQueue.deleteJob(monitor); + await this.db.deleteChecks(monitor._id); + await this.db.deletePageSpeedChecksByMonitorId(monitor._id); + await this.db.deleteNotificationsByMonitorId(monitor._id); + } catch (error) { + logger.error({ + message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, + service: SERVICE_NAME, + method: "deleteAllMonitors", + stack: error.stack, + }); + } + }) + ); + return res.success({ msg: `Deleted ${deletedCount} monitors` }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "deleteAllMonitors")); + } + }; + + /** + * Edits a monitor by its ID, updates its notifications, and updates its job in the job queue. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.params - The parameters of the request. + * @property {string} req.params.monitorId - The ID of the monitor to be edited. + * @property {Object} req.body - The body of the request. + * @property {Array} req.body.notifications - The notifications to be associated with the monitor. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the editing of the monitor, and the edited monitor data. + * @throws {Error} If there is an error during the process, especially if there is a validation error (422). + */ + editMonitor = async (req, res, next) => { + try { + await getMonitorByIdParamValidation.validateAsync(req.params); + await editMonitorBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const { monitorId } = req.params; + const monitorBeforeEdit = await this.db.getMonitorById(monitorId); + + // Get notifications from the request body + const notifications = req.body.notifications; + + const editedMonitor = await this.db.editMonitor(monitorId, req.body); + + await this.db.deleteNotificationsByMonitorId(editedMonitor._id); + + await Promise.all( + notifications && + notifications.map(async (notification) => { + notification.monitorId = editedMonitor._id; + await this.db.createNotification(notification); + }) + ); + + // Delete the old job(editedMonitor has the same ID as the old monitor) + await this.jobQueue.deleteJob(monitorBeforeEdit); + // Add the new job back to the queue + await this.jobQueue.addJob(editedMonitor._id, editedMonitor); + return res.success({ + msg: this.stringService.monitorEdit, + data: editedMonitor, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "editMonitor")); + } + }; + + /** + * Pauses or resumes a monitor based on its current state. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.params - The parameters of the request. + * @property {string} req.params.monitorId - The ID of the monitor to be paused or resumed. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the new state of the monitor, and the updated monitor data. + * @throws {Error} If there is an error during the process. + */ + pauseMonitor = async (req, res, next) => { + try { + await pauseMonitorParamValidation.validateAsync(req.params); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + } + + try { + const monitor = await Monitor.findOneAndUpdate({ _id: req.params.monitorId }, [ + { + $set: { + isActive: { $not: "$isActive" }, + status: "$$REMOVE", + }, + }, + ]); + monitor.isActive === true + ? await this.jobQueue.deleteJob(monitor) + : await this.jobQueue.addJob(monitor._id, monitor); + + return res.success({ + msg: monitor.isActive + ? this.stringService.monitorResume + : this.stringService.monitorPause, + data: monitor, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "pauseMonitor")); + } + }; + + /** + * Adds demo monitors for a team. + * @async + * @param {Object} req - The Express request object. + * @property {Object} req.headers - The headers of the request. + * @property {string} req.headers.authorization - The authorization header containing the JWT token. + * @param {Object} res - The Express response object. + * @param {function} next - The next middleware function. + * @returns {Object} The response object with a success status, a message indicating the addition of demo monitors, and the number of demo monitors added. + * @throws {Error} If there is an error during the process. + */ + addDemoMonitors = async (req, res, next) => { + try { + const token = getTokenFromHeaders(req.headers); + const { jwtSecret } = this.settingsService.getSettings(); + const { _id, teamId } = jwt.verify(token, jwtSecret); + const demoMonitors = await this.db.addDemoMonitors(_id, teamId); + await Promise.all( + demoMonitors.map((monitor) => this.jobQueue.addJob(monitor._id, monitor)) + ); + + return res.success({ + msg: this.stringService.monitorDemoAdded, + data: demoMonitors.length, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "addDemoMonitors")); + } + }; + + getMonitorsByTeamId = async (req, res, next) => { + try { + await getMonitorsByTeamIdParamValidation.validateAsync(req.params); + await getMonitorsByTeamIdQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + } + + try { + const monitors = await this.db.getMonitorsByTeamId(req); + return res.success({ + msg: this.stringService.monitorGetByTeamId, + data: monitors, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMonitorsByTeamId")); + } + }; + + getMonitorsAndSummaryByTeamId = async (req, res, next) => { + try { + await getMonitorsByTeamIdParamValidation.validateAsync(req.params); + await getMonitorsByTeamIdQueryValidation.validateAsync(req.query); + } catch (error) { + return next(handleValidationError(error, SERVICE_NAME)); + } + + try { + const result = await this.db.getMonitorsAndSummaryByTeamId(req); + return res.success({ + msg: "OK", // TODO + data: result, + }); + } catch (error) { + return next(handleError(error, SERVICE_NAME, "getMonitorsAndSummaryByTeamId")); + } + }; + + getMonitorsWithChecksByTeamId = async (req, res, next) => { + try { + await getMonitorsByTeamIdParamValidation.validateAsync(req.params); + await getMonitorsByTeamIdQueryValidation.validateAsync(req.query); + } catch (error) { + return next(handleValidationError(error, SERVICE_NAME)); + } + + try { + const result = await this.db.getMonitorsWithChecksByTeamId(req); + return res.success({ + msg: "OK", // TODO + data: result, + }); + } catch (error) { + return next(handleError(error, SERVICE_NAME, "getMonitorsWithChecksByTeamId")); + } + }; + + seedDb = async (req, res, next) => { + try { + const { type } = req.body; + const token = getTokenFromHeaders(req.headers); + const { jwtSecret } = this.settingsService.getSettings(); + const { _id, teamId } = jwt.verify(token, jwtSecret); + if (type === "distributed_test") { + await seedDistributedTest(_id, teamId); + } else { + await seedDb(_id, teamId); + } + res.success({ msg: "Database seeded" }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "seedDb")); + } + }; +} + +export default MonitorController; diff --git a/server/controllers/notificationController.js b/server/controllers/notificationController.js new file mode 100755 index 000000000..3682821eb --- /dev/null +++ b/server/controllers/notificationController.js @@ -0,0 +1,174 @@ +import { triggerNotificationBodyValidation } from "../validation/joi.js"; +import { handleError, handleValidationError } from "./controllerUtils.js"; + +const SERVICE_NAME = "NotificationController"; + +const NOTIFICATION_TYPES = { + WEBHOOK: 'webhook', + TELEGRAM: 'telegram' +}; + +const PLATFORMS = { + SLACK: 'slack', + DISCORD: 'discord', + TELEGRAM: 'telegram' +}; + +class NotificationController { + constructor(notificationService, stringService, statusService) { + this.notificationService = notificationService; + this.stringService = stringService; + this.statusService = statusService; + this.triggerNotification = this.triggerNotification.bind(this); + this.testWebhook = this.testWebhook.bind(this); + } + + async triggerNotification(req, res, next) { + try { + await triggerNotificationBodyValidation.validateAsync(req.body, { + abortEarly: false, + stripUnknown: true, + }); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const { monitorId, type, platform, config, status = false } = req.body; + + // Create a simplified networkResponse similar to what would come from monitoring + const networkResponse = { + monitorId, + status + }; + + // Use the statusService to get monitor details and handle status change logic + // This returns { monitor, statusChanged, prevStatus } exactly like your job queue uses + const statusResult = await this.statusService.updateStatus(networkResponse); + + if (type === NOTIFICATION_TYPES.WEBHOOK) { + const notification = { + type, + platform, + config, + }; + + await this.notificationService.sendWebhookNotification( + statusResult, // Contains monitor, statusChanged, and prevStatus + notification + ); + } + + return res.success({ + msg: this.stringService.webhookSendSuccess, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "triggerNotification")); + } + } + + createTestNetworkResponse() { + return { + monitor: { + _id: "test-monitor-id", + name: "Test Monitor", + url: "https://example.com" + }, + status: true, + statusChanged: true, + prevStatus: false, + }; + } + + handleTelegramTest(botToken, chatId) { + if (!botToken || !chatId) { + return { + isValid: false, + error: { + msg: this.stringService.telegramRequiresBotTokenAndChatId, + status: 400 + } + }; + } + + return { + isValid: true, + notification: { + type: NOTIFICATION_TYPES.WEBHOOK, + platform: PLATFORMS.TELEGRAM, + config: { botToken, chatId } + } + }; + } + + handleWebhookTest(webhookUrl, platform) { + if (webhookUrl === null) { + return { + isValid: false, + error: { + msg: this.stringService.webhookUrlRequired, + status: 400 + } + }; + } + + return { + isValid: true, + notification: { + type: NOTIFICATION_TYPES.WEBHOOK, + platform: platform, + config: { webhookUrl } + } + }; + } + + async testWebhook(req, res, next) { + try { + const { webhookUrl, platform, botToken, chatId } = req.body; + + if (platform === null) { + return res.error({ + msg: this.stringService.platformRequired, + status: 400 + }); + } + + // Platform-specific handling + const platformHandlers = { + [PLATFORMS.TELEGRAM]: () => this.handleTelegramTest(botToken, chatId), + // Default handler for webhook-based platforms (Slack, Discord, etc.) + default: () => this.handleWebhookTest(webhookUrl, platform) + }; + + const handler = platformHandlers[platform] || platformHandlers.default; + const handlerResult = handler(); + + if (!handlerResult.isValid) { + return res.error(handlerResult.error); + } + + const networkResponse = this.createTestNetworkResponse(); + + const result = await this.notificationService.sendWebhookNotification( + networkResponse, + handlerResult.notification + ); + + if (result && result !== false) { + return res.success({ + msg: this.stringService.webhookSendSuccess, + }); + } else { + return res.error({ + msg: this.stringService.testNotificationFailed, + status: 400 + }); + } + } catch (error) { + next(handleError(error, SERVICE_NAME, "testWebhook")); + } + } +} + +export default NotificationController; \ No newline at end of file diff --git a/server/controllers/queueController.js b/server/controllers/queueController.js new file mode 100755 index 000000000..9d87a70f6 --- /dev/null +++ b/server/controllers/queueController.js @@ -0,0 +1,87 @@ +import { handleError } from "./controllerUtils.js"; + +const SERVICE_NAME = "JobQueueController"; + +class JobQueueController { + constructor(jobQueue, stringService) { + this.jobQueue = jobQueue; + this.stringService = stringService; + } + + getMetrics = async (req, res, next) => { + try { + const metrics = await this.jobQueue.getMetrics(); + res.success({ + msg: this.stringService.queueGetMetrics, + data: metrics, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getMetrics")); + return; + } + }; + + getJobs = async (req, res, next) => { + try { + const jobs = await this.jobQueue.getJobStats(); + return res.success({ + msg: this.stringService.queueGetMetrics, + data: jobs, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getJobs")); + return; + } + }; + + addJob = async (req, res, next) => { + try { + await this.jobQueue.addJob(Math.random().toString(36).substring(7)); + return res.success({ + msg: this.stringService.queueAddJob, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "addJob")); + return; + } + }; + + obliterateQueue = async (req, res, next) => { + try { + await this.jobQueue.obliterate(); + return res.success({ + msg: this.stringService.queueObliterate, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "obliterateQueue")); + return; + } + }; + + flushQueue = async (req, res, next) => { + try { + const result = await this.jobQueue.flushQueue(); + return res.success({ + msg: this.stringService.jobQueueFlush, + data: result, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "flushQueue")); + return; + } + }; + + checkQueueHealth = async (req, res, next) => { + try { + const stuckQueues = await this.jobQueue.checkQueueHealth(); + return res.success({ + msg: this.stringService.queueHealthCheck, + data: stuckQueues, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "checkQueueHealth")); + return; + } + }; +} +export default JobQueueController; diff --git a/server/controllers/settingsController.js b/server/controllers/settingsController.js new file mode 100755 index 000000000..54643dfbb --- /dev/null +++ b/server/controllers/settingsController.js @@ -0,0 +1,48 @@ +import { updateAppSettingsBodyValidation } from "../validation/joi.js"; +import { handleValidationError, handleError } from "./controllerUtils.js"; + +const SERVICE_NAME = "SettingsController"; + +class SettingsController { + constructor(db, settingsService, stringService) { + this.db = db; + this.settingsService = settingsService; + this.stringService = stringService; + } + + getAppSettings = async (req, res, next) => { + try { + const settings = { ...(await this.settingsService.getSettings()) }; + delete settings.jwtSecret; + return res.success({ + msg: this.stringService.getAppSettings, + data: settings, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getAppSettings")); + } + }; + + updateAppSettings = async (req, res, next) => { + try { + await updateAppSettingsBodyValidation.validateAsync(req.body); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + await this.db.updateAppSettings(req.body); + const updatedSettings = { ...(await this.settingsService.reloadSettings()) }; + delete updatedSettings.jwtSecret; + return res.success({ + msg: this.stringService.updateAppSettings, + data: updatedSettings, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "updateAppSettings")); + } + }; +} + +export default SettingsController; diff --git a/server/controllers/statusPageController.js b/server/controllers/statusPageController.js new file mode 100755 index 000000000..d0bdcf4b5 --- /dev/null +++ b/server/controllers/statusPageController.js @@ -0,0 +1,143 @@ +import { handleError, handleValidationError } from "./controllerUtils.js"; +import { + createStatusPageBodyValidation, + getStatusPageParamValidation, + getStatusPageQueryValidation, + imageValidation, +} from "../validation/joi.js"; + +const SERVICE_NAME = "statusPageController"; + +class StatusPageController { + constructor(db, stringService) { + this.db = db; + this.stringService = stringService; + } + + createStatusPage = async (req, res, next) => { + try { + await createStatusPageBodyValidation.validateAsync(req.body); + await imageValidation.validateAsync(req.file); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const statusPage = await this.db.createStatusPage(req.body, req.file); + return res.success({ + msg: this.stringService.statusPageCreate, + data: statusPage, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "createStatusPage")); + } + }; + + updateStatusPage = async (req, res, next) => { + try { + await createStatusPageBodyValidation.validateAsync(req.body); + await imageValidation.validateAsync(req.file); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const statusPage = await this.db.updateStatusPage(req.body, req.file); + if (statusPage === null) { + const error = new Error(this.stringService.statusPageNotFound); + error.status = 404; + throw error; + } + return res.success({ + msg: this.stringService.statusPageUpdate, + data: statusPage, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "updateStatusPage")); + } + }; + + getStatusPage = async (req, res, next) => { + try { + const statusPage = await this.db.getStatusPage(); + return res.success({ + msg: this.stringService.statusPageByUrl, + data: statusPage, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getStatusPage")); + } + }; + + getDistributedStatusPageByUrl = async (req, res, next) => { + try { + await getStatusPageParamValidation.validateAsync(req.params); + await getStatusPageQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const statusPage = await this.db.getDistributedStatusPageByUrl({ + url: req.params.url, + daysToShow: req.params.timeFrame, + }); + return res.success({ + msg: this.stringService.statusPageByUrl, + data: statusPage, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getDistributedStatusPageByUrl")); + } + }; + + getStatusPageByUrl = async (req, res, next) => { + try { + await getStatusPageParamValidation.validateAsync(req.params); + await getStatusPageQueryValidation.validateAsync(req.query); + } catch (error) { + next(handleValidationError(error, SERVICE_NAME)); + return; + } + + try { + const statusPage = await this.db.getStatusPageByUrl(req.params.url, req.query.type); + return res.success({ + msg: this.stringService.statusPageByUrl, + data: statusPage, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getStatusPageByUrl")); + } + }; + + getStatusPagesByTeamId = async (req, res, next) => { + try { + const teamId = req.params.teamId; + const statusPages = await this.db.getStatusPagesByTeamId(teamId); + + return res.success({ + msg: this.stringService.statusPageByTeamId, + data: statusPages, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "getStatusPageByTeamId")); + } + }; + + deleteStatusPage = async (req, res, next) => { + try { + await this.db.deleteStatusPage(req.params.url); + return res.success({ + msg: this.stringService.statusPageDelete, + }); + } catch (error) { + next(handleError(error, SERVICE_NAME, "deleteStatusPage")); + } + }; +} + +export default StatusPageController; diff --git a/server/db/FakeDb.js b/server/db/FakeDb.js new file mode 100755 index 000000000..4e9b32992 --- /dev/null +++ b/server/db/FakeDb.js @@ -0,0 +1,138 @@ +// ************************** +// The idea here is to provide a layer of abstraction between the database and whoever is using it. +// Instead of directly calling mongoose methods, we can call the methods on the DB object. +// If this were Typescript or Java or Golang an interface would be implemented to ensure the methods are available. +// But we do the best we can with Javascript. +// +// If the methods are consistent all we have to do to swap out one DB for another is simply change the import. +// +// Example: +// We start with the fake DB: +// +// const db = require("../db/FakeDb"); +// const monitors = await db.getAllMonitors(); +// +// And when we want to swtich to a real DB, all we have to do is swap the import +// +// const db = require("../db/MongoDb"); +// const monitors = await db.getAllMonitors(); +// +// The rest of the code is the same, as all the `db` methods are standardized. +// ************************** + +const Monitor = require("./models/Monitor"); +const UserModel = require("./models/User"); +const bcrypt = require("bcrypt"); + +let FAKE_MONITOR_DATA = []; +const USERS = []; + +const connect = async () => { + try { + await console.log("Connected to FakeDB"); + } catch (error) { + console.error(error); + } +}; + +const insertUser = async (req, res) => { + try { + const newUser = new UserModel({ ...req.body }); + const salt = await bcrypt.genSalt(10); //genSalt is asynchronous, need to wait + newUser.password = await bcrypt.hash(newUser.password, salt); // hash is also async, need to eitehr await or use hashSync + USERS.push(newUser); + const userToReturn = { ...newUser._doc }; + delete userToReturn.password; + return userToReturn; + } catch (error) { + throw error; + } +}; + +const getUserByEmail = async (req, res) => { + const email = req.body.email; + try { + const idx = USERS.findIndex((user) => { + return user.email === email; + }); + if (idx === -1) { + return null; + } + return USERS[idx]; + } catch (error) { + throw new Error(`User with email ${email} not found`); + } +}; + +const getAllMonitors = async () => { + return FAKE_MONITOR_DATA; +}; + +const getMonitorById = async (monitorId) => { + const idx = FAKE_MONITOR_DATA.findIndex((monitor) => { + return monitor.id === monitorId; + }); + if (idx === -1) { + throw new Error(`Monitor with id ${monitorId} not found`); + } + return FAKE_MONITOR_DATA[idx]; +}; + +const getMonitorsByUserId = async (userId) => { + const userMonitors = FAKE_MONITOR_DATA.filter((monitor) => { + return monitor.userId === userId; + }); + + if (userMonitors.length === 0) { + throw new Error(`Monitors for user ${userId} not found`); + } + return userMonitors; +}; + +const createMonitor = async (req, res) => { + const monitor = new Monitor(req.body); + monitor.createdAt = Date.now(); + monitor.updatedAt = Date.now(); + FAKE_MONITOR_DATA.push(monitor); + return monitor; +}; + +const deleteMonitor = async (req, res) => { + const monitorId = req.params.monitorId; + try { + const monitor = getMonitorById(monitorId); + FAKE_MONITOR_DATA = FAKE_MONITOR_DATA.filter((monitor) => { + return monitor.id !== monitorId; + }); + return monitor; + } catch (error) { + throw error; + } +}; + +const editMonitor = async (req, res) => { + const monitorId = req.params.monitorId; + const idx = FAKE_MONITOR_DATA.findIndex((monitor) => { + return monitor._id.toString() === monitorId; + }); + const oldMonitor = FAKE_MONITOR_DATA[idx]; + const editedMonitor = new Monitor({ ...req.body }); + editedMonitor._id = oldMonitor._id; + editedMonitor.userId = oldMonitor.userId; + editedMonitor.updatedAt = Date.now(); + editedMonitor.createdAt = oldMonitor.createdAt; + FAKE_MONITOR_DATA[idx] = editedMonitor; + return FAKE_MONITOR_DATA[idx]; +}; + +module.exports = { + connect, + insertUser, + getUserByEmail, + getAllMonitors, + getMonitorById, + getMonitorsByUserId, + createMonitor, + deleteMonitor, + editMonitor, +}; diff --git a/server/db/models/AppSettings.js b/server/db/models/AppSettings.js new file mode 100755 index 000000000..26ba6f254 --- /dev/null +++ b/server/db/models/AppSettings.js @@ -0,0 +1,80 @@ +import mongoose from "mongoose"; + +const AppSettingsSchema = mongoose.Schema( + { + apiBaseUrl: { + type: String, + required: true, + default: "http://localhost:5000/api/v1", + }, + language: { + type: String, + default: "en", + }, + logLevel: { + type: String, + default: "debug", + enum: ["debug", "none", "error", "warn"], + }, + clientHost: { + type: String, + required: true, + default: "http://localhost:5173", + }, + jwtSecret: { + type: String, + required: true, + default: "my_secret", + }, + dbType: { + type: String, + required: true, + default: "MongoDB", + }, + dbConnectionString: { + type: String, + required: true, + default: "mongodb://localhost:27017/uptime_db", + }, + redisUrl: { + type: String, + default: "redis://127.0.0.1:6379", + }, + jwtTTL: { + type: String, + required: true, + default: "2h", + }, + pagespeedApiKey: { + type: String, + default: "", + }, + systemEmailHost: { + type: String, + default: "smtp.gmail.com", + }, + systemEmailPort: { + type: Number, + default: 465, + }, + systemEmailAddress: { + type: String, + default: "", + }, + systemEmailPassword: { + type: String, + default: "", + }, + singleton: { + type: Boolean, + required: true, + unique: true, + default: true, + }, + }, + { + timestamps: true, + } +); + +export default mongoose.model("AppSettings", AppSettingsSchema); \ No newline at end of file diff --git a/server/db/models/Check.js b/server/db/models/Check.js new file mode 100755 index 000000000..2361ea7a6 --- /dev/null +++ b/server/db/models/Check.js @@ -0,0 +1,83 @@ +import mongoose from "mongoose"; + +const BaseCheckSchema = mongoose.Schema({ + /** + * Reference to the associated Monitor document. + * + * @type {mongoose.Schema.Types.ObjectId} + */ + monitorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Monitor", + immutable: true, + index: true, + }, + + teamId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Team", + immutable: true, + index: true, + }, + /** + * Status of the check (true for up, false for down). + * + * @type {Boolean} + */ + status: { + type: Boolean, + index: true, + }, + /** + * Response time of the check in milliseconds. + * + * @type {Number} + */ + responseTime: { + type: Number, + }, + /** + * HTTP status code received during the check. + * + * @type {Number} + */ + statusCode: { + type: Number, + index: true, + }, + /** + * Message or description of the check result. + * + * @type {String} + */ + message: { + type: String, + }, + /** + * Expiry date of the check, auto-calculated to expire after 30 days. + * + * @type {Date} + */ + + expiry: { + type: Date, + default: Date.now, + expires: 60 * 60 * 24 * 30, // 30 days + }, +}); + +/** + * Check Schema for MongoDB collection. + * + * Represents a check associated with a monitor, storing information + * about the status and response of a particular check event. + */ +const CheckSchema = mongoose.Schema({ ...BaseCheckSchema.obj }, { timestamps: true }); +CheckSchema.index({ updatedAt: 1 }); +CheckSchema.index({ monitorId: 1, updatedAt: 1 }); +CheckSchema.index({ monitorId: 1, updatedAt: -1 }); +CheckSchema.index({ teamId: 1, updatedAt: -1 }); +CheckSchema.index({ teamId: 1 }); + +export default mongoose.model("Check", CheckSchema); +export { BaseCheckSchema }; diff --git a/server/db/models/DistributedUptimeCheck.js b/server/db/models/DistributedUptimeCheck.js new file mode 100755 index 000000000..cb88e2c31 --- /dev/null +++ b/server/db/models/DistributedUptimeCheck.js @@ -0,0 +1,137 @@ +import mongoose from "mongoose"; + +import { BaseCheckSchema } from "./Check.js"; + +// { +// "id": "12123", +// "result": { +// "task_arrived": "2025-01-13T19:21:37.463466602Z", +// "dns_start": "2025-01-14T00:21:33.1801319+05:00", +// "dns_end": "2025-01-14T00:21:33.4582552+05:00", +// "conn_start": "2025-01-14T00:21:33.1801319+05:00", +// "conn_end": "2025-01-14T00:21:33.7076318+05:00", +// "connect_start": "2025-01-14T00:21:33.4582552+05:00", +// "connect_end": "2025-01-14T00:21:33.541899+05:00", +// "tls_hand_shake_start": "2025-01-14T00:21:33.541899+05:00", +// "tls_hand_shake_end": "2025-01-14T00:21:33.7076318+05:00", +// "body_read_start": "2025-01-14T00:21:34.1894707+05:00", +// "body_read_end": "2025-01-14T00:21:34.1894707+05:00", +// "wrote_request": "2025-01-14T00:21:33.7076318+05:00", +// "got_first_response_byte": "2025-01-14T00:21:34.1327652+05:00", +// "first_byte_took": 425133400, +// "body_read_took": 56030000, +// "dns_took": 278123300, +// "conn_took": 527499900, +// "connect_took": 83643800, +// "tls_took": 165732800, +// "sni_name": "uprock.com", +// "status_code": 200, +// "body_size": 19320, +// "request_header_size": 95, +// "response_header_size": 246, +// "response_headers": "X-Vercel-Id: bom1::iad1::sm87v-1736796096856-aec270c01f23\nDate: Mon, 13 Jan 2025 19:21:37 GMT\nServer: Vercel\nStrict-Transport-Security: max-age=63072000\nVary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Url\nX-Matched-Path: /\nX-Powered-By: Next.js\nX-Vercel-Cache: MISS\nAge: 0\nCache-Control: private, no-cache, no-store, max-age=0, must-revalidate\nContent-Type: text/html; charset=utf-8", +// "error": "", +// "device_id": "d5f578e143a2cd603dd6bf5f846a86a538bde4a8fbe2ad1fca284ad9f033daf8", +// "ip_address": "223.123.19.0", +// "proof": "", +// "created_at": "2025-01-13T19:21:37.463466912Z", +// "continent": "AS", +// "country_code": "PK", +// "city": "Muzaffargarh", +// "upt_burnt" : "0.01", +// "location": { +// "lat": 71.0968, +// "lng": 30.0208 +// }, +// "payload": { +// "callback": "https://webhook.site/2a15b0af-545a-4ac2-b913-153b97592d7a", +// "x": "y" +// } +// } +// } + +const LocationSchema = new mongoose.Schema( + { + lat: { type: Number, required: true }, + lng: { type: Number, required: true }, + }, + { _id: false } +); + +const DistributedUptimeCheckSchema = mongoose.Schema( + { + ...BaseCheckSchema.obj, + first_byte_took: { + type: Number, + required: false, + }, + body_read_took: { + type: Number, + required: false, + }, + dns_took: { + type: Number, + required: false, + }, + conn_took: { + type: Number, + required: false, + }, + connect_took: { + type: Number, + required: false, + }, + tls_took: { + type: Number, + required: false, + }, + location: { + type: LocationSchema, + required: false, + }, + continent: { + type: String, + required: false, + }, + countryCode: { + type: String, + required: false, + }, + city: { + type: String, + required: false, + }, + uptBurnt: { + type: mongoose.Schema.Types.Decimal128, + required: false, + }, + count: { + type: Number, + required: false, + }, + }, + { timestamps: true } +); + +DistributedUptimeCheckSchema.pre("save", function (next) { + if (this.isModified("uptBurnt") && typeof this.uptBurnt === "string") { + this.uptBurnt = mongoose.Types.Decimal128.fromString(this.uptBurnt); + } + next(); +}); + +DistributedUptimeCheckSchema.index({ createdAt: 1 }); +DistributedUptimeCheckSchema.index({ monitorId: 1, updatedAt: 1 }); +DistributedUptimeCheckSchema.index({ monitorId: 1, updatedAt: -1 }); +DistributedUptimeCheckSchema.index( + { + monitorId: 1, + createdAt: -1, + city: 1, + "location.lat": 1, + "location.lng": 1, + responseTime: 1, + }, + { background: true } +); +export default mongoose.model("DistributedUptimeCheck", DistributedUptimeCheckSchema); diff --git a/server/db/models/HardwareCheck.js b/server/db/models/HardwareCheck.js new file mode 100755 index 000000000..76b9dfc2e --- /dev/null +++ b/server/db/models/HardwareCheck.js @@ -0,0 +1,70 @@ +import mongoose from "mongoose"; +import { BaseCheckSchema } from "./Check.js"; +const cpuSchema = mongoose.Schema({ + physical_core: { type: Number, default: 0 }, + logical_core: { type: Number, default: 0 }, + frequency: { type: Number, default: 0 }, + temperature: { type: [Number], default: [] }, + free_percent: { type: Number, default: 0 }, + usage_percent: { type: Number, default: 0 }, +}); + +const memorySchema = mongoose.Schema({ + total_bytes: { type: Number, default: 0 }, + available_bytes: { type: Number, default: 0 }, + used_bytes: { type: Number, default: 0 }, + usage_percent: { type: Number, default: 0 }, +}); + +const diskSchema = mongoose.Schema({ + read_speed_bytes: { type: Number, default: 0 }, + write_speed_bytes: { type: Number, default: 0 }, + total_bytes: { type: Number, default: 0 }, + free_bytes: { type: Number, default: 0 }, + usage_percent: { type: Number, default: 0 }, +}); + +const hostSchema = mongoose.Schema({ + os: { type: String, default: "" }, + platform: { type: String, default: "" }, + kernel_version: { type: String, default: "" }, +}); + +const errorSchema = mongoose.Schema({ + metric: { type: [String], default: [] }, + err: { type: String, default: "" }, +}); + +const HardwareCheckSchema = mongoose.Schema( + { + ...BaseCheckSchema.obj, + cpu: { + type: cpuSchema, + default: () => ({}), + }, + memory: { + type: memorySchema, + default: () => ({}), + }, + disk: { + type: [diskSchema], + default: () => [], + }, + host: { + type: hostSchema, + default: () => ({}), + }, + + errors: { + type: [errorSchema], + default: () => [], + }, + }, + { timestamps: true } +); + +HardwareCheckSchema.index({ createdAt: 1 }); +HardwareCheckSchema.index({ monitorId: 1, createdAt: 1 }); +HardwareCheckSchema.index({ monitorId: 1, createdAt: -1 }); + +export default mongoose.model("HardwareCheck", HardwareCheckSchema); diff --git a/server/db/models/InviteToken.js b/server/db/models/InviteToken.js new file mode 100755 index 000000000..0c2402b07 --- /dev/null +++ b/server/db/models/InviteToken.js @@ -0,0 +1,34 @@ +import mongoose from "mongoose"; +const InviteTokenSchema = mongoose.Schema( + { + email: { + type: String, + required: true, + unique: true, + }, + teamId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + role: { + type: Array, + required: true, + }, + token: { + type: String, + required: true, + }, + expiry: { + type: Date, + default: Date.now, + expires: 3600, + }, + }, + { + timestamps: true, + } +); + +export default mongoose.model("InviteToken", InviteTokenSchema); diff --git a/server/db/models/MaintenanceWindow.js b/server/db/models/MaintenanceWindow.js new file mode 100755 index 000000000..b68abb413 --- /dev/null +++ b/server/db/models/MaintenanceWindow.js @@ -0,0 +1,68 @@ +import mongoose from "mongoose"; +/** + * MaintenanceWindow Schema + * @module MaintenanceWindow + * @typedef {Object} MaintenanceWindow + * @property {mongoose.Schema.Types.ObjectId} monitorId - The ID of the monitor. This is a reference to the Monitor model and is immutable. + * @property {Boolean} active - Indicates whether the maintenance window is active. + * @property {Number} repeat - Indicates how often this maintenance window should repeat. + * @property {Date} start - The start date and time of the maintenance window. + * @property {Date} end - The end date and time of the maintenance window. + * @property {Date} expiry - The expiry date and time of the maintenance window. This is used for MongoDB's TTL index to automatically delete the document at this time. This field is set to the same value as `end` when `oneTime` is `true`. + * + * @example + * + * let maintenanceWindow = new MaintenanceWindow({ + * monitorId: monitorId, + * active: active, + * repeat: repeat, + * start: start, + * end: end, + * }); + * + * if (repeat === 0) { + * maintenanceWindow.expiry = end; + * } + * + */ + +const MaintenanceWindow = mongoose.Schema( + { + monitorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Monitor", + immutable: true, + }, + teamId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Team", + immutable: true, + }, + active: { + type: Boolean, + default: true, + }, + name: { + type: String, + }, + repeat: { + type: Number, + }, + start: { + type: Date, + }, + end: { + type: Date, + }, + expiry: { + type: Date, + index: { expires: "0s" }, + }, + }, + + { + timestamps: true, + } +); + +export default mongoose.model("MaintenanceWindow", MaintenanceWindow); diff --git a/server/db/models/Monitor.js b/server/db/models/Monitor.js new file mode 100755 index 000000000..e9552aa60 --- /dev/null +++ b/server/db/models/Monitor.js @@ -0,0 +1,98 @@ +import mongoose from "mongoose"; + +const MonitorSchema = mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + immutable: true, + required: true, + }, + teamId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + name: { + type: String, + required: true, + }, + description: { + type: String, + }, + status: { + type: Boolean, + default: undefined, + }, + type: { + type: String, + required: true, + enum: [ + "http", + "ping", + "pagespeed", + "hardware", + "docker", + "port", + "distributed_http", + "distributed_test", + ], + }, + jsonPath: { + type: String, + }, + expectedValue: { + type: String, + }, + matchMethod: { + type: String, + enum: ["equal", "include", "regex"], + }, + url: { + type: String, + required: true, + }, + port: { + type: Number, + }, + isActive: { + type: Boolean, + default: true, + }, + interval: { + // in milliseconds + type: Number, + default: 60000, + }, + uptimePercentage: { + type: Number, + default: undefined, + }, + thresholds: { + type: { + usage_cpu: { type: Number }, + usage_memory: { type: Number }, + usage_disk: { type: Number }, + usage_temperature: { type: Number }, + }, + _id: false, + }, + notifications: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "Notification", + }, + ], + secret: { + type: String, + }, + }, + { + timestamps: true, + } +); + +MonitorSchema.index({ teamId: 1, type: 1 }); + +export default mongoose.model("Monitor", MonitorSchema); diff --git a/server/db/models/MonitorStats.js b/server/db/models/MonitorStats.js new file mode 100755 index 000000000..c28811b33 --- /dev/null +++ b/server/db/models/MonitorStats.js @@ -0,0 +1,53 @@ +import mongoose from "mongoose"; + +const MonitorStatsSchema = new mongoose.Schema( + { + monitorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Monitor", + immutable: true, + index: true, + }, + avgResponseTime: { + type: Number, + default: 0, + }, + totalChecks: { + type: Number, + default: 0, + }, + totalUpChecks: { + type: Number, + default: 0, + }, + totalDownChecks: { + type: Number, + default: 0, + }, + uptimePercentage: { + type: Number, + default: 0, + }, + lastCheckTimestamp: { + type: Number, + default: 0, + }, + lastResponseTime: { + type: Number, + default: 0, + }, + timeOfLastFailure: { + type: Number, + default: 0, + }, + uptBurnt: { + type: mongoose.Schema.Types.Decimal128, + required: false, + }, + }, + { timestamps: true } +); + +const MonitorStats = mongoose.model("MonitorStats", MonitorStatsSchema); + +export default MonitorStats; diff --git a/server/db/models/Notification.js b/server/db/models/Notification.js new file mode 100755 index 000000000..43eb73c93 --- /dev/null +++ b/server/db/models/Notification.js @@ -0,0 +1,97 @@ +import mongoose from "mongoose"; + +const configSchema = mongoose.Schema( + { + webhookUrl: { type: String }, + botToken: { type: String }, + chatId: { type: String }, + }, + { _id: false } +); + +const NotificationSchema = mongoose.Schema( + { + monitorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Monitor", + immutable: true, + }, + type: { + type: String, + enum: ["email", "sms", "webhook"], + }, + platform: { + type: String, + }, + config: { + type: configSchema, + default: () => ({}), + }, + address: { + type: String, + }, + phone: { + type: String, + }, + alertThreshold: { + type: Number, + default: 5, + }, + cpuAlertThreshold: { + type: Number, + default: function () { + return this.alertThreshold; + }, + }, + memoryAlertThreshold: { + type: Number, + default: function () { + return this.alertThreshold; + }, + }, + diskAlertThreshold: { + type: Number, + default: function () { + return this.alertThreshold; + }, + }, + tempAlertThreshold: { + type: Number, + default: function () { + return this.alertThreshold; + }, + }, + }, + { + timestamps: true, + } +); + +NotificationSchema.pre("save", function (next) { + if (!this.cpuAlertThreshold || this.isModified("alertThreshold")) { + this.cpuAlertThreshold = this.alertThreshold; + } + if (!this.memoryAlertThreshold || this.isModified("alertThreshold")) { + this.memoryAlertThreshold = this.alertThreshold; + } + if (!this.diskAlertThreshold || this.isModified("alertThreshold")) { + this.diskAlertThreshold = this.alertThreshold; + } + if (!this.tempAlertThreshold || this.isModified("alertThreshold")) { + this.tempAlertThreshold = this.alertThreshold; + } + next(); +}); + +NotificationSchema.pre("findOneAndUpdate", function (next) { + const update = this.getUpdate(); + if (update.alertThreshold) { + update.cpuAlertThreshold = update.alertThreshold; + update.memoryAlertThreshold = update.alertThreshold; + update.diskAlertThreshold = update.alertThreshold; + update.tempAlertThreshold = update.alertThreshold; + } + next(); +}); + +export default mongoose.model("Notification", NotificationSchema); diff --git a/server/db/models/PageSpeedCheck.js b/server/db/models/PageSpeedCheck.js new file mode 100755 index 000000000..513dd3039 --- /dev/null +++ b/server/db/models/PageSpeedCheck.js @@ -0,0 +1,85 @@ +import mongoose from "mongoose"; +import { BaseCheckSchema } from "./Check.js"; +import logger from "../../utils/logger.js"; +import { time } from "console"; +const AuditSchema = mongoose.Schema({ + id: { type: String, required: true }, + title: { type: String, required: true }, + description: { type: String, required: true }, + score: { type: Number, required: true }, + scoreDisplayMode: { type: String, required: true }, + displayValue: { type: String, required: true }, + numericValue: { type: Number, required: true }, + numericUnit: { type: String, required: true }, +}); + +const AuditsSchema = mongoose.Schema({ + cls: { + type: AuditSchema, + required: true, + }, + si: { + type: AuditSchema, + required: true, + }, + fcp: { + type: AuditSchema, + required: true, + }, + lcp: { + type: AuditSchema, + required: true, + }, + tbt: { + type: AuditSchema, + required: true, + }, +}); + +/** + * Mongoose schema for storing metrics from Google Lighthouse. + * @typedef {Object} PageSpeedCheck + * @property {mongoose.Schema.Types.ObjectId} monitorId - Reference to the Monitor model. + * @property {number} accessibility - Accessibility score. + * @property {number} bestPractices - Best practices score. + * @property {number} seo - SEO score. + * @property {number} performance - Performance score. + */ + +const PageSpeedCheck = mongoose.Schema( + { + ...BaseCheckSchema.obj, + accessibility: { + type: Number, + required: true, + }, + bestPractices: { + type: Number, + required: true, + }, + seo: { + type: Number, + required: true, + }, + performance: { + type: Number, + required: true, + }, + audits: { + type: AuditsSchema, + required: true, + }, + }, + { timestamps: true } +); + +/** + * Mongoose model for storing metrics from Google Lighthouse. + * @typedef {mongoose.Model} LighthouseMetricsModel + */ + +PageSpeedCheck.index({ createdAt: 1 }); +PageSpeedCheck.index({ monitorId: 1, createdAt: 1 }); +PageSpeedCheck.index({ monitorId: 1, createdAt: -1 }); + +export default mongoose.model("PageSpeedCheck", PageSpeedCheck); diff --git a/server/db/models/RecoveryToken.js b/server/db/models/RecoveryToken.js new file mode 100755 index 000000000..2219a4bca --- /dev/null +++ b/server/db/models/RecoveryToken.js @@ -0,0 +1,25 @@ +import mongoose from "mongoose"; + +const RecoveryTokenSchema = mongoose.Schema( + { + email: { + type: String, + required: true, + unique: true, + }, + token: { + type: String, + required: true, + }, + expiry: { + type: Date, + default: Date.now, + expires: 600, + }, + }, + { + timestamps: true, + } +); + +export default mongoose.model("RecoveryToken", RecoveryTokenSchema); diff --git a/server/db/models/StatusPage.js b/server/db/models/StatusPage.js new file mode 100755 index 000000000..ab7be6955 --- /dev/null +++ b/server/db/models/StatusPage.js @@ -0,0 +1,77 @@ +import mongoose from "mongoose"; + +const StatusPageSchema = mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + immutable: true, + required: true, + }, + teamId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Team", + immutable: true, + required: true, + }, + type: { + type: String, + required: true, + default: "uptime", + enum: ["uptime", "distributed"], + }, + companyName: { + type: String, + required: true, + default: "", + }, + url: { + type: String, + unique: true, + required: true, + default: "", + }, + timezone: { + type: String, + required: false, + }, + color: { + type: String, + required: false, + default: "#4169E1", + }, + monitors: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "Monitor", + required: true, + }, + ], + subMonitors: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "Monitor", + required: true, + }, + ], + logo: { + data: Buffer, + contentType: String, + }, + isPublished: { + type: Boolean, + default: false, + }, + showCharts: { + type: Boolean, + default: true, + }, + showUptimePercentage: { + type: Boolean, + default: true, + }, + }, + { timestamps: true } +); + +export default mongoose.model("StatusPage", StatusPageSchema); diff --git a/server/db/models/Team.js b/server/db/models/Team.js new file mode 100755 index 000000000..95337c201 --- /dev/null +++ b/server/db/models/Team.js @@ -0,0 +1,14 @@ +import mongoose from "mongoose"; +const TeamSchema = mongoose.Schema( + { + email: { + type: String, + required: true, + unique: true, + }, + }, + { + timestamps: true, + } +); +export default mongoose.model("Team", TeamSchema); diff --git a/server/db/models/User.js b/server/db/models/User.js new file mode 100755 index 000000000..3ddc5cee0 --- /dev/null +++ b/server/db/models/User.js @@ -0,0 +1,92 @@ +import mongoose from "mongoose"; +import bcrypt from "bcrypt"; +import logger from "../../utils/logger.js"; + +const UserSchema = mongoose.Schema( + { + firstName: { + type: String, + required: true, + }, + lastName: { + type: String, + required: true, + }, + email: { + type: String, + required: true, + unique: true, + }, + password: { + type: String, + required: true, + }, + avatarImage: { + type: String, + }, + profileImage: { + data: Buffer, + contentType: String, + }, + isActive: { + type: Boolean, + default: true, + }, + isVerified: { + type: Boolean, + default: false, + }, + role: { + type: [String], + default: "user", + enum: ["user", "admin", "superadmin", "demo"], + }, + teamId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Team", + immutable: true, + }, + checkTTL: { + type: Number, + }, + }, + { + timestamps: true, + } +); + +UserSchema.pre("save", async function (next) { + if (!this.isModified("password")) { + next(); + } + const salt = await bcrypt.genSalt(10); //genSalt is asynchronous, need to wait + this.password = await bcrypt.hash(this.password, salt); // hash is also async, need to eitehr await or use hashSync + next(); +}); + +UserSchema.pre("findOneAndUpdate", async function (next) { + const update = this.getUpdate(); + if ("password" in update) { + const salt = await bcrypt.genSalt(10); //genSalt is asynchronous, need to wait + update.password = await bcrypt.hash(update.password, salt); // hash is also async, need to eitehr await or use hashSync + } + + next(); +}); + +UserSchema.methods.comparePassword = async function (submittedPassword) { + const res = await bcrypt.compare(submittedPassword, this.password); + return res; +}; + +const User = mongoose.model("User", UserSchema); + +User.init().then(() => { + logger.info({ + message: "User model initialized", + service: "UserModel", + method: "init", + }); +}); + +export default mongoose.model("User", UserSchema); diff --git a/server/db/mongo/MongoDB.js b/server/db/mongo/MongoDB.js new file mode 100755 index 000000000..8237504d6 --- /dev/null +++ b/server/db/mongo/MongoDB.js @@ -0,0 +1,156 @@ +import mongoose from "mongoose"; +import UserModel from "../models/User.js"; +import AppSettings from "../models/AppSettings.js"; +import logger from "../../utils/logger.js"; + +//**************************************** +// User Operations +//**************************************** + +import * as userModule from "./modules/userModule.js"; + +//**************************************** +// Invite Token Operations +//**************************************** + +import * as inviteModule from "./modules/inviteModule.js"; + +//**************************************** +// Recovery Operations +//**************************************** +import * as recoveryModule from "./modules/recoveryModule.js"; + +//**************************************** +// Monitors +//**************************************** + +import * as monitorModule from "./modules/monitorModule.js"; + +//**************************************** +// Page Speed Checks +//**************************************** + +import * as pageSpeedCheckModule from "./modules/pageSpeedCheckModule.js"; + +//**************************************** +// Hardware Checks +//**************************************** +import * as hardwareCheckModule from "./modules/hardwareCheckModule.js"; + +//**************************************** +// Checks +//**************************************** + +import * as checkModule from "./modules/checkModule.js"; + +//**************************************** +// Distributed Checks +//**************************************** +import * as distributedCheckModule from "./modules/distributedCheckModule.js"; + +//**************************************** +// Maintenance Window +//**************************************** +import * as maintenanceWindowModule from "./modules/maintenanceWindowModule.js"; + +//**************************************** +// Notifications +//**************************************** +import * as notificationModule from "./modules/notificationModule.js"; + +//**************************************** +// AppSettings +//**************************************** +import * as settingsModule from "./modules/settingsModule.js"; + +//**************************************** +// Status Page +//**************************************** +import * as statusPageModule from "./modules/statusPageModule.js"; + +//**************************************** +// Diagnostic +//**************************************** +import * as diagnosticModule from "./modules/diagnosticModule.js"; + +class MongoDB { + static SERVICE_NAME = "MongoDB"; + + constructor() { + Object.assign(this, userModule); + Object.assign(this, inviteModule); + Object.assign(this, recoveryModule); + Object.assign(this, monitorModule); + Object.assign(this, pageSpeedCheckModule); + Object.assign(this, hardwareCheckModule); + Object.assign(this, checkModule); + Object.assign(this, distributedCheckModule); + Object.assign(this, maintenanceWindowModule); + Object.assign(this, notificationModule); + Object.assign(this, settingsModule); + Object.assign(this, statusPageModule); + Object.assign(this, diagnosticModule); + } + + connect = async () => { + try { + const connectionString = + process.env.DB_CONNECTION_STRING || "mongodb://localhost:27017/uptime_db"; + await mongoose.connect(connectionString); + // If there are no AppSettings, create one + await AppSettings.findOneAndUpdate( + {}, // empty filter to match any document + {}, // empty update + { + new: true, + setDefaultsOnInsert: true, + } + ); + // Sync indexes + const models = mongoose.modelNames(); + for (const modelName of models) { + const model = mongoose.model(modelName); + await model.syncIndexes(); + } + + logger.info({ + message: "Connected to MongoDB", + service: this.SERVICE_NAME, + method: "connect", + }); + } catch (error) { + logger.error({ + message: error.message, + service: this.SERVICE_NAME, + method: "connect", + stack: error.stack, + }); + throw error; + } + }; + + disconnect = async () => { + try { + logger.info({ message: "Disconnecting from MongoDB" }); + await mongoose.disconnect(); + logger.info({ message: "Disconnected from MongoDB" }); + return; + } catch (error) { + logger.error({ + message: error.message, + service: this.SERVICE_NAME, + method: "disconnect", + stack: error.stack, + }); + } + }; + checkSuperadmin = async (req, res) => { + const superAdmin = await UserModel.findOne({ role: "superadmin" }); + if (superAdmin !== null) { + return true; + } + return false; + }; +} + +export default MongoDB; diff --git a/server/db/mongo/modules/checkModule.js b/server/db/mongo/modules/checkModule.js new file mode 100755 index 000000000..2b0eb33ba --- /dev/null +++ b/server/db/mongo/modules/checkModule.js @@ -0,0 +1,319 @@ +import Check from "../../models/Check.js"; +import Monitor from "../../models/Monitor.js"; +import HardwareCheck from "../../models/HardwareCheck.js"; +import PageSpeedCheck from "../../models/PageSpeedCheck.js"; +import DistributedUptimeCheck from "../../models/DistributedUptimeCheck.js"; +import User from "../../models/User.js"; +import logger from "../../../utils/logger.js"; +import { ObjectId } from "mongodb"; + +const SERVICE_NAME = "checkModule"; +const dateRangeLookup = { + recent: new Date(new Date().setDate(new Date().getDate() - 2)), + hour: new Date(new Date().setHours(new Date().getHours() - 1)), + day: new Date(new Date().setDate(new Date().getDate() - 1)), + week: new Date(new Date().setDate(new Date().getDate() - 7)), + month: new Date(new Date().setMonth(new Date().getMonth() - 1)), + all: undefined, +}; + +/** + * Create a check for a monitor + * @async + * @param {Object} checkData + * @param {string} checkData.monitorId + * @param {boolean} checkData.status + * @param {number} checkData.responseTime + * @param {number} checkData.statusCode + * @param {string} checkData.message + * @returns {Promise} + * @throws {Error} + */ + +const createCheck = async (checkData) => { + try { + const check = await new Check({ ...checkData }).save(); + return check; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createCheck"; + throw error; + } +}; + +const createChecks = async (checks) => { + try { + await Check.insertMany(checks); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createCheck"; + throw error; + } +}; + +/** + * Get all checks for a monitor + * @async + * @param {string} monitorId + * @returns {Promise>} + * @throws {Error} + */ +const getChecksByMonitor = async (req) => { + try { + const { monitorId } = req.params; + let { type, sortOrder, dateRange, filter, page, rowsPerPage, status } = req.query; + status = typeof status !== "undefined" ? false : undefined; + page = parseInt(page); + rowsPerPage = parseInt(rowsPerPage); + // Match + const matchStage = { + monitorId: ObjectId.createFromHexString(monitorId), + ...(typeof status !== "undefined" && { status }), + ...(dateRangeLookup[dateRange] && { + createdAt: { + $gte: dateRangeLookup[dateRange], + }, + }), + }; + + if (filter !== undefined) { + switch (filter) { + case "all": + break; + case "down": + break; + case "resolve": + matchStage.statusCode = 5000; + break; + default: + logger.warn({ + message: "invalid filter", + service: SERVICE_NAME, + method: "getChecks", + }); + break; + } + } + + //Sort + sortOrder = sortOrder === "asc" ? 1 : -1; + + // Pagination + let skip = 0; + if (page && rowsPerPage) { + skip = page * rowsPerPage; + } + + const checkModels = { + http: Check, + ping: Check, + docker: Check, + port: Check, + pagespeed: PageSpeedCheck, + hardware: HardwareCheck, + distributed_http: DistributedUptimeCheck, + distributed_test: DistributedUptimeCheck, + }; + + const Model = checkModels[type]; + + const checks = await Model.aggregate([ + { $match: matchStage }, + { $sort: { createdAt: sortOrder } }, + { + $facet: { + summary: [{ $count: "checksCount" }], + checks: [{ $skip: skip }, { $limit: rowsPerPage }], + }, + }, + { + $project: { + checksCount: { + $ifNull: [{ $arrayElemAt: ["$summary.checksCount", 0] }, 0], + }, + checks: { + $ifNull: ["$checks", []], + }, + }, + }, + ]); + return checks[0]; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getChecks"; + throw error; + } +}; + +const getChecksByTeam = async (req) => { + try { + let { sortOrder, dateRange, filter, page, rowsPerPage } = req.query; + page = parseInt(page); + rowsPerPage = parseInt(rowsPerPage); + const { teamId } = req.params; + const matchStage = { + teamId: ObjectId.createFromHexString(teamId), + status: false, + ...(dateRangeLookup[dateRange] && { + createdAt: { + $gte: dateRangeLookup[dateRange], + }, + }), + }; + // Add filter to match stage + if (filter !== undefined) { + switch (filter) { + case "all": + break; + case "down": + break; + case "resolve": + matchStage.statusCode = 5000; + break; + default: + logger.warn({ + message: "invalid filter", + service: SERVICE_NAME, + method: "getChecksByTeam", + }); + break; + } + } + + sortOrder = sortOrder === "asc" ? 1 : -1; + + // pagination + let skip = 0; + if (page && rowsPerPage) { + skip = page * rowsPerPage; + } + + const aggregatePipeline = [ + { $match: matchStage }, + { + $unionWith: { + coll: "hardwarechecks", + pipeline: [{ $match: matchStage }], + }, + }, + { + $unionWith: { + coll: "pagespeedchecks", + pipeline: [{ $match: matchStage }], + }, + }, + { + $unionWith: { + coll: "distributeduptimechecks", + pipeline: [{ $match: matchStage }], + }, + }, + { $sort: { createdAt: sortOrder } }, + { + $facet: { + summary: [{ $count: "checksCount" }], + checks: [{ $skip: skip }, { $limit: rowsPerPage }], + }, + }, + { + $project: { + checksCount: { $arrayElemAt: ["$summary.checksCount", 0] }, + checks: "$checks", + }, + }, + ]; + + const checks = await Check.aggregate(aggregatePipeline); + return checks[0]; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getChecksByTeam"; + throw error; + } +}; + +/** + * Delete all checks for a monitor + * @async + * @param {string} monitorId + * @returns {number} + * @throws {Error} + */ + +const deleteChecks = async (monitorId) => { + try { + const result = await Check.deleteMany({ monitorId }); + return result.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteChecks"; + throw error; + } +}; + +/** + * Delete all checks for a team + * @async + * @param {string} monitorId + * @returns {number} + * @throws {Error} + */ + +const deleteChecksByTeamId = async (teamId) => { + try { + // Find all monitor IDs for this team (only get _id field for efficiency) + const teamMonitors = await Monitor.find({ teamId }, { _id: 1 }); + const monitorIds = teamMonitors.map((monitor) => monitor._id); + + // Delete all checks for these monitors in one operation + const deleteResult = await Check.deleteMany({ monitorId: { $in: monitorIds } }); + + return deleteResult.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteChecksByTeamId"; + throw error; + } +}; + +const updateChecksTTL = async (teamId, ttl) => { + try { + await Check.collection.dropIndex("expiry_1"); + } catch (error) { + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "updateChecksTTL", + stack: error.stack, + }); + } + + try { + await Check.collection.createIndex( + { expiry: 1 }, + { expireAfterSeconds: ttl } // TTL in seconds, adjust as necessary + ); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "updateChecksTTL"; + throw error; + } + // Update user + try { + await User.updateMany({ teamId: teamId }, { checkTTL: ttl }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "updateChecksTTL"; + throw error; + } +}; + +export { + createCheck, + createChecks, + getChecksByMonitor, + getChecksByTeam, + deleteChecks, + deleteChecksByTeamId, + updateChecksTTL, +}; diff --git a/server/db/mongo/modules/diagnosticModule.js b/server/db/mongo/modules/diagnosticModule.js new file mode 100755 index 000000000..2fd8acc71 --- /dev/null +++ b/server/db/mongo/modules/diagnosticModule.js @@ -0,0 +1,107 @@ +import DistributedUptimeCheck from "../../models/DistributedUptimeCheck.js"; +import Monitor from "../../models/Monitor.js"; +import { ObjectId } from "mongodb"; + +const SERVICE_NAME = "diagnosticModule"; +import { + buildMonitorSummaryByTeamIdPipeline, + buildMonitorsByTeamIdPipeline, + buildFilteredMonitorsByTeamIdPipeline, + buildDePINDetailsByDateRange, + buildDePINLatestChecks, +} from "./monitorModuleQueries.js"; +import { getDateRange } from "./monitorModule.js"; + +const getDistributedUptimeDbExecutionStats = async (req) => { + try { + const { monitorId } = req?.params ?? {}; + if (typeof monitorId === "undefined") { + throw new Error(); + } + const monitor = await Monitor.findById(monitorId); + if (monitor === null || monitor === undefined) { + throw new Error(this.stringService.dbFindMonitorById(monitorId)); + } + + const { dateRange } = req.query; + const dates = getDateRange(dateRange); + const formatLookup = { + recent: "%Y-%m-%dT%H:%M:00Z", + day: { + $concat: [ + { $dateToString: { format: "%Y-%m-%dT%H:", date: "$createdAt" } }, + { + $cond: [{ $lt: [{ $minute: "$createdAt" }, 30] }, "00:00Z", "30:00Z"], + }, + ], + }, + week: "%Y-%m-%dT%H:00:00Z", + month: "%Y-%m-%dT00:00:00Z", + }; + + const dateString = formatLookup[dateRange]; + + const dePINDetailsByDateRangeStats = await DistributedUptimeCheck.aggregate( + buildDePINDetailsByDateRange(monitor, dates, dateString) + ).explain("executionStats"); + const latestChecksStats = await DistributedUptimeCheck.aggregate( + buildDePINLatestChecks(monitor) + ).explain("executionStats"); + + return { + dePINDetailsByDateRangeStats, + latestChecksStats, + }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getAllMonitorsWithUptimeStats"; + throw error; + } +}; + +const getMonitorsByTeamIdExecutionStats = async (req) => { + try { + let { limit, type, page, rowsPerPage, filter, field, order } = req.query; + limit = parseInt(limit); + page = parseInt(page); + rowsPerPage = parseInt(rowsPerPage); + if (field === undefined) { + field = "name"; + order = "asc"; + } + // Build match stage + const matchStage = { teamId: ObjectId.createFromHexString(req.params.teamId) }; + if (type !== undefined) { + matchStage.type = Array.isArray(type) ? { $in: type } : type; + } + + const summary = await Monitor.aggregate( + buildMonitorSummaryByTeamIdPipeline({ matchStage }) + ).explain("executionStats"); + + const monitors = await Monitor.aggregate( + buildMonitorsByTeamIdPipeline({ matchStage, field, order }) + ).explain("executionStats"); + + const filteredMonitors = await Monitor.aggregate( + buildFilteredMonitorsByTeamIdPipeline({ + matchStage, + filter, + page, + rowsPerPage, + field, + order, + limit, + type, + }) + ).explain("executionStats"); + + return { summary, monitors, filteredMonitors }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorSummaryByTeamIdExecutionStats"; + throw error; + } +}; + +export { getDistributedUptimeDbExecutionStats, getMonitorsByTeamIdExecutionStats }; diff --git a/server/db/mongo/modules/distributedCheckModule.js b/server/db/mongo/modules/distributedCheckModule.js new file mode 100755 index 000000000..10fc577b2 --- /dev/null +++ b/server/db/mongo/modules/distributedCheckModule.js @@ -0,0 +1,157 @@ +import DistributedUptimeCheck from "../../models/DistributedUptimeCheck.js"; +import { ObjectId } from "mongodb"; + +const SERVICE_NAME = "distributedCheckModule"; + +const createDistributedCheck = async (checkData) => { + try { + if (typeof checkData.monitorId === "string") { + checkData.monitorId = ObjectId.createFromHexString(checkData.monitorId); + } + const check = await DistributedUptimeCheck.findOneAndUpdate( + { + monitorId: checkData.monitorId, + city: checkData.city, + }, + [ + { + $set: { + ...checkData, + + responseTime: { + $cond: { + if: { $ifNull: ["$count", false] }, + then: { + $cond: { + // Check if the new value is an outlier (3x the current average) + if: { + $and: [ + { $gt: ["$responseTime", 0] }, + { + $gt: [ + checkData.responseTime, + { $multiply: ["$responseTime", 3] }, + ], + }, + ], + }, + then: "$responseTime", // Keep the current value if it's an outlier + else: { + // Normal case - calculate new average + $round: [ + { + $divide: [ + { + $add: [ + { $multiply: ["$responseTime", "$count"] }, + checkData.responseTime, + ], + }, + { $add: ["$count", 1] }, + ], + }, + 2, + ], + }, + }, + }, + else: checkData.responseTime, + }, + }, + count: { $add: [{ $ifNull: ["$count", 0] }, 1] }, + }, + }, + ], + { + upsert: true, + new: true, + runValidators: true, + } + ); + return check; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createCheck"; + throw error; + } +}; + +const createDistributedChecks = async (checksData) => { + try { + if (!Array.isArray(checksData) || checksData.length === 0) { + return; + } + + const bulkOps = checksData.map((checkData) => { + if (typeof checkData.monitorId === "string") { + checkData.monitorId = ObjectId.createFromHexString(checkData.monitorId); + } + + return { + updateOne: { + filter: { + monitorId: checkData.monitorId, + city: checkData.city, + }, + update: [ + { + $set: { + ...checkData, + responseTime: { + $cond: { + if: { $ifNull: ["$count", false] }, + then: { + $round: [ + { + $divide: [ + { + $add: [ + { $multiply: ["$responseTime", "$count"] }, + checkData.responseTime, + ], + }, + { $add: ["$count", 1] }, + ], + }, + 2, + ], + }, + else: checkData.responseTime, + }, + }, + count: { $add: [{ $ifNull: ["$count", 0] }, 1] }, + }, + }, + ], + upsert: true, + }, + }; + }); + + // Execute bulk operation + await DistributedUptimeCheck.bulkWrite(bulkOps, { + ordered: false, // Allow parallel processing + }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createDistributedChecks"; + throw error; + } +}; + +const deleteDistributedChecksByMonitorId = async (monitorId) => { + try { + const result = await DistributedUptimeCheck.deleteMany({ monitorId }); + return result.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteDistributedChecksByMonitorId"; + throw error; + } +}; + +export { + createDistributedCheck, + createDistributedChecks, + deleteDistributedChecksByMonitorId, +}; diff --git a/server/db/mongo/modules/hardwareCheckModule.js b/server/db/mongo/modules/hardwareCheckModule.js new file mode 100755 index 000000000..738412c60 --- /dev/null +++ b/server/db/mongo/modules/hardwareCheckModule.js @@ -0,0 +1,74 @@ +import HardwareCheck from "../../models/HardwareCheck.js"; +import Monitor from "../../models/Monitor.js"; +import logger from "../../../utils/logger.js"; + +const SERVICE_NAME = "hardwareCheckModule"; +const createHardwareCheck = async (hardwareCheckData) => { + try { + const { monitorId, status } = hardwareCheckData; + const n = (await HardwareCheck.countDocuments({ monitorId })) + 1; + const monitor = await Monitor.findById(monitorId); + + if (!monitor) { + logger.error({ + message: "Monitor not found", + service: SERVICE_NAME, + method: "createHardwareCheck", + details: `monitor ID: ${monitorId}`, + }); + return null; + } + + let newUptimePercentage; + if (monitor.uptimePercentage === undefined) { + newUptimePercentage = status === true ? 1 : 0; + } else { + newUptimePercentage = + (monitor.uptimePercentage * (n - 1) + (status === true ? 1 : 0)) / n; + } + + await Monitor.findOneAndUpdate( + { _id: monitorId }, + { uptimePercentage: newUptimePercentage } + ); + + const hardwareCheck = await new HardwareCheck({ + ...hardwareCheckData, + }).save(); + return hardwareCheck; + } catch (error) { + logger.error({ + message: "Error creating hardware check", + service: SERVICE_NAME, + method: "createHardwareCheck", + stack: error.stack, + }); + error.service = SERVICE_NAME; + error.method = "createHardwareCheck"; + throw error; + } +}; + +const createHardwareChecks = async (hardwareChecks) => { + try { + await HardwareCheck.insertMany(hardwareChecks); + return true; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createHardwareChecks"; + throw error; + } +}; + +const deleteHardwareChecksByMonitorId = async (monitorId) => { + try { + const result = await HardwareCheck.deleteMany({ monitorId }); + return result.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteHardwareChecksByMonitorId"; + throw error; + } +}; + +export { createHardwareCheck, createHardwareChecks, deleteHardwareChecksByMonitorId }; diff --git a/server/db/mongo/modules/inviteModule.js b/server/db/mongo/modules/inviteModule.js new file mode 100755 index 000000000..f5c960697 --- /dev/null +++ b/server/db/mongo/modules/inviteModule.js @@ -0,0 +1,89 @@ +import InviteToken from "../../models/InviteToken.js"; +import crypto from "crypto"; +import ServiceRegistry from "../../../service/serviceRegistry.js"; +import StringService from "../../../service/stringService.js"; + +const SERVICE_NAME = "inviteModule"; +/** + * Request an invite token for a user. + * + * This function deletes any existing invite tokens for the user's email, + * generates a new token, saves it, and then returns the new token. + * + * @param {Object} userData - The user data. + * @param {string} userData.email - The user's email. + * @param {mongoose.Schema.Types.ObjectId} userData.teamId - The ID of the team. + * @param {Array} userData.role - The user's role(s). + * @param {Date} [userData.expiry=Date.now] - The expiry date of the token. Defaults to the current date and time. + * @returns {Promise} The invite token. + * @throws {Error} If there is an error. + */ +const requestInviteToken = async (userData) => { + try { + await InviteToken.deleteMany({ email: userData.email }); + userData.token = crypto.randomBytes(32).toString("hex"); + let inviteToken = new InviteToken(userData); + await inviteToken.save(); + return inviteToken; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "requestInviteToken"; + throw error; + } +}; + +/** + * Retrieves an invite token + * + * This function searches for an invite token in the database and deletes it. + * If the invite token is not found, it throws an error. + * + * @param {string} token - The invite token to search for. + * @returns {Promise} The invite token data. + * @throws {Error} If the invite token is not found or there is another error. + */ +const getInviteToken = async (token) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + try { + const invite = await InviteToken.findOne({ + token, + }); + if (invite === null) { + throw new Error(stringService.authInviteNotFound); + } + return invite; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getInviteToken"; + throw error; + } +}; + +/** + * Retrieves and deletes an invite token + * + * This function searches for an invite token in the database and deletes it. + * If the invite token is not found, it throws an error. + * + * @param {string} token - The invite token to search for. + * @returns {Promise} The invite token data. + * @throws {Error} If the invite token is not found or there is another error. + */ +const getInviteTokenAndDelete = async (token) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + try { + const invite = await InviteToken.findOneAndDelete({ + token, + }); + if (invite === null) { + throw new Error(stringService.authInviteNotFound); + } + return invite; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getInviteTokenAndDelete"; + throw error; + } +}; + +export { requestInviteToken, getInviteToken, getInviteTokenAndDelete }; diff --git a/server/db/mongo/modules/maintenanceWindowModule.js b/server/db/mongo/modules/maintenanceWindowModule.js new file mode 100755 index 000000000..959f3f961 --- /dev/null +++ b/server/db/mongo/modules/maintenanceWindowModule.js @@ -0,0 +1,199 @@ +import MaintenanceWindow from "../../models/MaintenanceWindow.js"; +const SERVICE_NAME = "maintenanceWindowModule"; + +/** + * Asynchronously creates a new MaintenanceWindow document and saves it to the database. + * If the maintenance window is a one-time event, the expiry field is set to the same value as the end field. + * @async + * @function createMaintenanceWindow + * @param {Object} maintenanceWindowData - The data for the new MaintenanceWindow document. + * @param {mongoose.Schema.Types.ObjectId} maintenanceWindowData.monitorId - The ID of the monitor. + * @param {Boolean} maintenanceWindowData.active - Indicates whether the maintenance window is active. + * @param {Boolean} maintenanceWindowData.oneTime - Indicates whether the maintenance window is a one-time event. + * @param {Date} maintenanceWindowData.start - The start date and time of the maintenance window. + * @param {Date} maintenanceWindowData.end - The end date and time of the maintenance window. + * @returns {Promise} The saved MaintenanceWindow document. + * @throws {Error} If there is an error saving the document. + */ +const createMaintenanceWindow = async (maintenanceWindowData) => { + try { + const maintenanceWindow = new MaintenanceWindow({ + ...maintenanceWindowData, + }); + + // If the maintenance window is a one time window, set the expiry to the end date + if (maintenanceWindowData.oneTime) { + maintenanceWindow.expiry = maintenanceWindowData.end; + } + const result = await maintenanceWindow.save(); + return result; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createMaintenanceWindow"; + throw error; + } +}; + +const getMaintenanceWindowById = async (maintenanceWindowId) => { + try { + const maintenanceWindow = await MaintenanceWindow.findById({ + _id: maintenanceWindowId, + }); + return maintenanceWindow; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMaintenanceWindowById"; + throw error; + } +}; + +/** + * Asynchronously retrieves all MaintenanceWindow documents associated with a specific team ID. + * @async + * @function getMaintenanceWindowByUserId + * @param {String} teamId - The ID of the team. + * @param {Object} query - The request body. + * @returns {Promise>} An array of MaintenanceWindow documents. + * @throws {Error} If there is an error retrieving the documents. + */ +const getMaintenanceWindowsByTeamId = async (teamId, query) => { + try { + let { active, page, rowsPerPage, field, order } = query || {}; + const maintenanceQuery = { teamId }; + + if (active !== undefined) maintenanceQuery.active = active; + + const maintenanceWindowCount = + await MaintenanceWindow.countDocuments(maintenanceQuery); + + // Pagination + let skip = 0; + if (page && rowsPerPage) { + skip = page * rowsPerPage; + } + + // Sorting + let sort = {}; + if (field !== undefined && order !== undefined) { + sort[field] = order === "asc" ? 1 : -1; + } + + const maintenanceWindows = await MaintenanceWindow.find(maintenanceQuery) + .skip(skip) + .limit(rowsPerPage) + .sort(sort); + + return { maintenanceWindows, maintenanceWindowCount }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMaintenanceWindowByUserId"; + throw error; + } +}; + +/** + * Asynchronously retrieves all MaintenanceWindow documents associated with a specific monitor ID. + * @async + * @function getMaintenanceWindowsByMonitorId + * @param {mongoose.Schema.Types.ObjectId} monitorId - The ID of the monitor. + * @returns {Promise>} An array of MaintenanceWindow documents. + * @throws {Error} If there is an error retrieving the documents. + */ +const getMaintenanceWindowsByMonitorId = async (monitorId) => { + try { + const maintenanceWindows = await MaintenanceWindow.find({ + monitorId: monitorId, + }); + return maintenanceWindows; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMaintenanceWindowsByMonitorId"; + throw error; + } +}; + +/** + * Asynchronously deletes a MaintenanceWindow document by its ID. + * @async + * @function deleteMaintenanceWindowById + * @param {mongoose.Schema.Types.ObjectId} maintenanceWindowId - The ID of the MaintenanceWindow document to delete. + * @returns {Promise} The deleted MaintenanceWindow document. + * @throws {Error} If there is an error deleting the document. + */ +const deleteMaintenanceWindowById = async (maintenanceWindowId) => { + try { + const maintenanceWindow = + await MaintenanceWindow.findByIdAndDelete(maintenanceWindowId); + return maintenanceWindow; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteMaintenanceWindowById"; + throw error; + } +}; + +/** + * Asynchronously deletes all MaintenanceWindow documents associated with a specific monitor ID. + * @async + * @function deleteMaintenanceWindowByMonitorId + * @param {mongoose.Schema.Types.ObjectId} monitorId - The ID of the monitor. + * @returns {Promise} The result of the delete operation. This object contains information about the operation, such as the number of documents deleted. + * @throws {Error} If there is an error deleting the documents. + * @example + */ +const deleteMaintenanceWindowByMonitorId = async (monitorId) => { + try { + const result = await MaintenanceWindow.deleteMany({ monitorId: monitorId }); + return result; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteMaintenanceWindowByMonitorId"; + throw error; + } +}; + +/** + * Asynchronously deletes all MaintenanceWindow documents associated with a specific user ID. + * @async + * @function deleteMaintenanceWindowByUserId + * @param {String} userId - The ID of the user. + * @returns {Promise} The result of the delete operation. This object contains information about the operation, such as the number of documents deleted. + * @throws {Error} If there is an error deleting the documents. + * @example + */ +const deleteMaintenanceWindowByUserId = async (userId) => { + try { + const result = await MaintenanceWindow.deleteMany({ userId: userId }); + return result; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteMaintenanceWindowByUserId"; + throw error; + } +}; + +const editMaintenanceWindowById = async (maintenanceWindowId, maintenanceWindowData) => { + try { + const editedMaintenanceWindow = await MaintenanceWindow.findByIdAndUpdate( + maintenanceWindowId, + maintenanceWindowData, + { new: true } + ); + return editedMaintenanceWindow; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "editMaintenanceWindowById"; + throw error; + } +}; + +export { + createMaintenanceWindow, + getMaintenanceWindowById, + getMaintenanceWindowsByTeamId, + getMaintenanceWindowsByMonitorId, + deleteMaintenanceWindowById, + deleteMaintenanceWindowByMonitorId, + deleteMaintenanceWindowByUserId, + editMaintenanceWindowById, +}; diff --git a/server/db/mongo/modules/monitorModule.js b/server/db/mongo/modules/monitorModule.js new file mode 100755 index 000000000..1cea930ac --- /dev/null +++ b/server/db/mongo/modules/monitorModule.js @@ -0,0 +1,922 @@ +import Monitor from "../../models/Monitor.js"; +import MonitorStats from "../../models/MonitorStats.js"; +import Check from "../../models/Check.js"; +import PageSpeedCheck from "../../models/PageSpeedCheck.js"; +import HardwareCheck from "../../models/HardwareCheck.js"; +import DistributedUptimeCheck from "../../models/DistributedUptimeCheck.js"; +import Notification from "../../models/Notification.js"; +import { NormalizeData, NormalizeDataUptimeDetails } from "../../../utils/dataUtils.js"; +import ServiceRegistry from "../../../service/serviceRegistry.js"; +import StringService from "../../../service/stringService.js"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { ObjectId } from "mongodb"; + +import { + buildUptimeDetailsPipeline, + buildHardwareDetailsPipeline, + buildMonitorStatsPipeline, + buildMonitorSummaryByTeamIdPipeline, + buildMonitorsByTeamIdPipeline, + buildMonitorsAndSummaryByTeamIdPipeline, + buildMonitorsWithChecksByTeamIdPipeline, + buildFilteredMonitorsByTeamIdPipeline, + buildDePINDetailsByDateRange, + buildDePINLatestChecks, +} from "./monitorModuleQueries.js"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const demoMonitorsPath = path.resolve(__dirname, "../../../utils/demoMonitors.json"); +const demoMonitors = JSON.parse(fs.readFileSync(demoMonitorsPath, "utf8")); + +const SERVICE_NAME = "monitorModule"; + +const CHECK_MODEL_LOOKUP = { + http: Check, + ping: Check, + docker: Check, + port: Check, + pagespeed: PageSpeedCheck, + hardware: HardwareCheck, +}; + +/** + * Get all monitors + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise>} + * @throws {Error} + */ +const getAllMonitors = async (req, res) => { + try { + const monitors = await Monitor.find(); + return monitors; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getAllMonitors"; + throw error; + } +}; + +/** + * Get all monitors with uptime stats for 1,7,30, and 90 days + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise>} + * @throws {Error} + */ +const getAllMonitorsWithUptimeStats = async () => { + const timeRanges = { + 1: new Date(Date.now() - 24 * 60 * 60 * 1000), + 7: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + 30: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + 90: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), + }; + + try { + const monitors = await Monitor.find(); + const monitorsWithStats = await Promise.all( + monitors.map(async (monitor) => { + const model = CHECK_MODEL_LOOKUP[monitor.type]; + + const uptimeStats = await Promise.all( + Object.entries(timeRanges).map(async ([days, startDate]) => { + const checks = await model.find({ + monitorId: monitor._id, + createdAt: { $gte: startDate }, + }); + return [days, getUptimePercentage(checks)]; + }) + ); + + return { + ...monitor.toObject(), + ...Object.fromEntries(uptimeStats), + }; + }) + ); + + return monitorsWithStats; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getAllMonitorsWithUptimeStats"; + throw error; + } +}; + +/** + * Function to calculate uptime duration based on the most recent check. + * @param {Array} checks Array of check objects. + * @returns {number} Uptime duration in ms. + */ +const calculateUptimeDuration = (checks) => { + if (!checks || checks.length === 0) { + return 0; + } + const latestCheck = new Date(checks[0].createdAt); + let latestDownCheck = 0; + + for (let i = checks.length - 1; i >= 0; i--) { + if (checks[i].status === false) { + latestDownCheck = new Date(checks[i].createdAt); + break; + } + } + + // If no down check is found, uptime is from the last check to now + if (latestDownCheck === 0) { + return Date.now() - new Date(checks[checks.length - 1].createdAt); + } + + // Otherwise the uptime is from the last check to the last down check + return latestCheck - latestDownCheck; +}; + +/** + * Helper function to get duration since last check + * @param {Array} checks Array of check objects. + * @returns {number} Timestamp of the most recent check. + */ +const getLastChecked = (checks) => { + if (!checks || checks.length === 0) { + return 0; // Handle case when no checks are available + } + // Data is sorted newest->oldest, so last check is the most recent + return new Date() - new Date(checks[0].createdAt); +}; + +/** + * Helper function to get latestResponseTime + * @param {Array} checks Array of check objects. + * @returns {number} Timestamp of the most recent check. + */ +const getLatestResponseTime = (checks) => { + if (!checks || checks.length === 0) { + return 0; + } + + return checks[0]?.responseTime ?? 0; +}; + +/** + * Helper function to get average response time + * @param {Array} checks Array of check objects. + * @returns {number} Timestamp of the most recent check. + */ +const getAverageResponseTime = (checks) => { + if (!checks || checks.length === 0) { + return 0; + } + + const validChecks = checks.filter((check) => typeof check.responseTime === "number"); + if (validChecks.length === 0) { + return 0; + } + const aggResponseTime = validChecks.reduce((sum, check) => { + return sum + check.responseTime; + }, 0); + return aggResponseTime / validChecks.length; +}; + +/** + * Helper function to get percentage 24h uptime + * @param {Array} checks Array of check objects. + * @returns {number} Timestamp of the most recent check. + */ + +const getUptimePercentage = (checks) => { + if (!checks || checks.length === 0) { + return 0; + } + const upCount = checks.reduce((count, check) => { + return check.status === true ? count + 1 : count; + }, 0); + return (upCount / checks.length) * 100; +}; + +/** + * Helper function to get all incidents + * @param {Array} checks Array of check objects. + * @returns {number} Timestamp of the most recent check. + */ + +const getIncidents = (checks) => { + if (!checks || checks.length === 0) { + return 0; // Handle case when no checks are available + } + return checks.reduce((acc, check) => { + return check.status === false ? (acc += 1) : acc; + }, 0); +}; + +/** + * Get date range parameters + * @param {string} dateRange - 'day' | 'week' | 'month' | 'all' + * @returns {Object} Start and end dates + */ +const getDateRange = (dateRange) => { + const startDates = { + recent: new Date(new Date().setHours(new Date().getHours() - 2)), + day: new Date(new Date().setDate(new Date().getDate() - 1)), + week: new Date(new Date().setDate(new Date().getDate() - 7)), + month: new Date(new Date().setMonth(new Date().getMonth() - 1)), + all: new Date(0), + }; + return { + start: startDates[dateRange], + end: new Date(), + }; +}; + +/** + * Get checks for a monitor + * @param {string} monitorId - Monitor ID + * @param {Object} model - Check model to use + * @param {Object} dateRange - Date range parameters + * @param {number} sortOrder - Sort order (1 for ascending, -1 for descending) + * @returns {Promise} All checks and date-ranged checks + */ +const getMonitorChecks = async (monitorId, model, dateRange, sortOrder) => { + const indexSpec = { + monitorId: 1, + createdAt: sortOrder, // This will be 1 or -1 + }; + + const [checksAll, checksForDateRange] = await Promise.all([ + model.find({ monitorId }).sort({ createdAt: sortOrder }).hint(indexSpec).lean(), + model + .find({ + monitorId, + createdAt: { $gte: dateRange.start, $lte: dateRange.end }, + }) + .hint(indexSpec) + .lean(), + ]); + + return { checksAll, checksForDateRange }; +}; + +/** + * Process checks for display + * @param {Array} checks - Checks to process + * @param {number} numToDisplay - Number of checks to display + * @param {boolean} normalize - Whether to normalize the data + * @returns {Array} Processed checks + */ +const processChecksForDisplay = (normalizeData, checks, numToDisplay, normalize) => { + let processedChecks = checks; + if (numToDisplay && checks.length > numToDisplay) { + const n = Math.ceil(checks.length / numToDisplay); + processedChecks = checks.filter((_, index) => index % n === 0); + } + return normalize ? normalizeData(processedChecks, 1, 100) : processedChecks; +}; + +/** + * Get time-grouped checks based on date range + * @param {Array} checks Array of check objects + * @param {string} dateRange 'day' | 'week' | 'month' + * @returns {Object} Grouped checks by time period + */ +const groupChecksByTime = (checks, dateRange) => { + return checks.reduce((acc, check) => { + // Validate the date + const checkDate = new Date(check.createdAt); + if (Number.isNaN(checkDate.getTime()) || checkDate.getTime() === 0) { + return acc; + } + + const time = + dateRange === "day" + ? checkDate.setMinutes(0, 0, 0) + : checkDate.toISOString().split("T")[0]; + + if (!acc[time]) { + acc[time] = { time, checks: [] }; + } + acc[time].checks.push(check); + return acc; + }, {}); +}; + +/** + * Calculate aggregate stats for a group of checks + * @param {Object} group Group of checks + * @returns {Object} Stats for the group + */ +const calculateGroupStats = (group) => { + const totalChecks = group.checks.length; + + const checksWithResponseTime = group.checks.filter( + (check) => typeof check.responseTime === "number" && !Number.isNaN(check.responseTime) + ); + + return { + time: group.time, + uptimePercentage: getUptimePercentage(group.checks), + totalChecks, + totalIncidents: group.checks.filter((check) => !check.status).length, + avgResponseTime: + checksWithResponseTime.length > 0 + ? checksWithResponseTime.reduce((sum, check) => sum + check.responseTime, 0) / + checksWithResponseTime.length + : 0, + }; +}; + +/** + * Get uptime details by monitor ID + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const getUptimeDetailsById = async (req) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + try { + const { monitorId } = req.params; + const { dateRange, normalize } = req.query; + const dates = getDateRange(dateRange); + const formatLookup = { + recent: "%Y-%m-%dT%H:%M:00Z", + day: "%Y-%m-%dT%H:00:00Z", + week: "%Y-%m-%dT%H:00:00Z", + month: "%Y-%m-%dT00:00:00Z", + }; + + const dateString = formatLookup[dateRange]; + + const results = await Check.aggregate( + buildUptimeDetailsPipeline(monitorId, dates, dateString) + ); + + const monitorData = results[0]; + + monitorData.groupedUpChecks = NormalizeDataUptimeDetails( + monitorData.groupedUpChecks, + 10, + 100 + ); + + monitorData.groupedDownChecks = NormalizeDataUptimeDetails( + monitorData.groupedDownChecks, + 10, + 100 + ); + + const normalizedGroupChecks = NormalizeDataUptimeDetails( + monitorData.groupedChecks, + 10, + 100 + ); + + monitorData.groupedChecks = normalizedGroupChecks; + const monitorStats = await MonitorStats.findOne({ monitorId }); + return { monitorData, monitorStats }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getUptimeDetailsById"; + throw error; + } +}; + +const getDistributedUptimeDetailsById = async (req) => { + try { + const { monitorId } = req?.params ?? {}; + if (typeof monitorId === "undefined") { + throw new Error(); + } + const monitor = await Monitor.findById(monitorId); + if (monitor === null || monitor === undefined) { + throw new Error(this.stringService.dbFindMonitorById(monitorId)); + } + + const { dateRange, normalize } = req.query; + const dates = getDateRange(dateRange); + const formatLookup = { + recent: "%Y-%m-%dT%H:%M:00Z", + day: { + $concat: [ + { $dateToString: { format: "%Y-%m-%dT%H:", date: "$updatedAt" } }, + { + $cond: [{ $lt: [{ $minute: "$updatedAt" }, 30] }, "00:00Z", "30:00Z"], + }, + ], + }, + week: "%Y-%m-%dT%H:00:00Z", + month: "%Y-%m-%dT00:00:00Z", + }; + + const dateString = formatLookup[dateRange]; + + const monitorStatsResult = await MonitorStats.aggregate( + buildMonitorStatsPipeline(monitor) + ); + const monitorStats = monitorStatsResult[0]; + const dePINDetailsByDateRange = await DistributedUptimeCheck.aggregate( + buildDePINDetailsByDateRange(monitor, dates, dateString) + ); + const latestChecks = await DistributedUptimeCheck.aggregate( + buildDePINLatestChecks(monitor) + ); + + const checkData = dePINDetailsByDateRange[0]; + const normalizedGroupChecks = NormalizeDataUptimeDetails( + checkData.groupedChecks, + 10, + 100 + ); + const data = { + ...monitor.toObject(), + latestChecks, + totalChecks: monitorStats?.totalChecks, + avgResponseTime: monitorStats?.avgResponseTime, + uptimePercentage: monitorStats?.uptimePercentage, + timeSinceLastCheck: monitorStats?.timeSinceLastCheck, + uptBurnt: monitorStats?.uptBurnt, + groupedChecks: normalizedGroupChecks, + groupedMapChecks: checkData.groupedMapChecks, + }; + + return data; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getDistributedUptimeDetailsById"; + throw error; + } +}; + +/** + * Get stats by monitor ID + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const getMonitorStatsById = async (req) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + try { + const { monitorId } = req.params; + + // Get monitor, if we can't find it, abort with error + const monitor = await Monitor.findById(monitorId); + if (monitor === null || monitor === undefined) { + throw new Error(stringService.getDbFindMonitorById(monitorId)); + } + + // Get query params + let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query; + const sort = sortOrder === "asc" ? 1 : -1; + + // Get Checks for monitor in date range requested + const model = CHECK_MODEL_LOOKUP[monitor.type]; + const dates = getDateRange(dateRange); + const { checksAll, checksForDateRange } = await getMonitorChecks( + monitorId, + model, + dates, + sort + ); + + // Build monitor stats + const monitorStats = { + ...monitor.toObject(), + uptimeDuration: calculateUptimeDuration(checksAll), + lastChecked: getLastChecked(checksAll), + latestResponseTime: getLatestResponseTime(checksAll), + periodIncidents: getIncidents(checksForDateRange), + periodTotalChecks: checksForDateRange.length, + checks: processChecksForDisplay( + NormalizeData, + checksForDateRange, + numToDisplay, + normalize + ), + }; + + if ( + monitor.type === "http" || + monitor.type === "ping" || + monitor.type === "docker" || + monitor.type === "port" + ) { + // HTTP/PING Specific stats + monitorStats.periodAvgResponseTime = getAverageResponseTime(checksForDateRange); + monitorStats.periodUptime = getUptimePercentage(checksForDateRange); + const groupedChecks = groupChecksByTime(checksForDateRange, dateRange); + monitorStats.aggregateData = Object.values(groupedChecks).map(calculateGroupStats); + } + + return monitorStats; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorStatsById"; + throw error; + } +}; + +const getHardwareDetailsById = async (req) => { + try { + const { monitorId } = req.params; + const { dateRange } = req.query; + const monitor = await Monitor.findById(monitorId); + const dates = getDateRange(dateRange); + const formatLookup = { + recent: "%Y-%m-%dT%H:%M:00Z", + day: "%Y-%m-%dT%H:00:00Z", + week: "%Y-%m-%dT%H:00:00Z", + month: "%Y-%m-%dT00:00:00Z", + }; + const dateString = formatLookup[dateRange]; + const hardwareStats = await HardwareCheck.aggregate( + buildHardwareDetailsPipeline(monitor, dates, dateString) + ); + + const monitorStats = { + ...monitor.toObject(), + stats: hardwareStats[0], + }; + return monitorStats; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getHardwareDetailsById"; + throw error; + } +}; + +/** + * Get a monitor by ID + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const getMonitorById = async (monitorId) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + try { + const monitor = await Monitor.findById(monitorId); + if (monitor === null || monitor === undefined) { + const error = new Error(stringService.getDbFindMonitorById(monitorId)); + error.status = 404; + throw error; + } + // Get notifications + const notifications = await Notification.find({ + monitorId: monitorId, + }); + + // Update monitor with notifications and save + monitor.notifications = notifications; + await monitor.save(); + + return monitor; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorById"; + throw error; + } +}; + +const getMonitorsByTeamId = async (req) => { + let { limit, type, page, rowsPerPage, filter, field, order } = req.query; + limit = parseInt(limit); + page = parseInt(page); + rowsPerPage = parseInt(rowsPerPage); + if (field === undefined) { + field = "name"; + order = "asc"; + } + // Build match stage + const matchStage = { teamId: ObjectId.createFromHexString(req.params.teamId) }; + if (type !== undefined) { + matchStage.type = Array.isArray(type) ? { $in: type } : type; + } + + const summaryResult = await Monitor.aggregate( + buildMonitorSummaryByTeamIdPipeline({ matchStage }) + ); + const summary = summaryResult[0]; + + const monitors = await Monitor.aggregate( + buildMonitorsByTeamIdPipeline({ matchStage, field, order }) + ); + + const filteredMonitors = await Monitor.aggregate( + buildFilteredMonitorsByTeamIdPipeline({ + matchStage, + filter, + page, + rowsPerPage, + field, + order, + limit, + type, + }) + ); + + const normalizedFilteredMonitors = filteredMonitors.map((monitor) => { + if (!monitor.checks) { + return monitor; + } + monitor.checks = NormalizeData(monitor.checks, 10, 100); + return monitor; + }); + + return { summary, monitors, filteredMonitors: normalizedFilteredMonitors }; +}; + +const getMonitorsAndSummaryByTeamId = async (req) => { + try { + const { type } = req.query; + const teamId = ObjectId.createFromHexString(req.params.teamId); + const matchStage = { teamId }; + if (type !== undefined) { + matchStage.type = Array.isArray(type) ? { $in: type } : type; + } + + if (req.explain === true) { + return Monitor.aggregate( + buildMonitorsAndSummaryByTeamIdPipeline({ matchStage }) + ).explain("executionStats"); + } + + const queryResult = await Monitor.aggregate( + buildMonitorsAndSummaryByTeamIdPipeline({ matchStage }) + ); + const { monitors, summary } = queryResult?.[0] ?? {}; + return { monitors, summary }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorsAndSummaryByTeamId"; + throw error; + } +}; + +const getMonitorsWithChecksByTeamId = async (req) => { + try { + let { limit, type, page, rowsPerPage, filter, field, order } = req.query; + limit = parseInt(limit); + page = parseInt(page); + rowsPerPage = parseInt(rowsPerPage); + if (field === undefined) { + field = "name"; + order = "asc"; + } + const teamId = ObjectId.createFromHexString(req.params.teamId); + // Build match stage + const matchStage = { teamId }; + if (type !== undefined) { + matchStage.type = Array.isArray(type) ? { $in: type } : type; + } + + if (req.explain === true) { + return Monitor.aggregate( + buildMonitorsWithChecksByTeamIdPipeline({ + matchStage, + filter, + page, + rowsPerPage, + field, + order, + limit, + type, + }) + ).explain("executionStats"); + } + + const queryResult = await Monitor.aggregate( + buildMonitorsWithChecksByTeamIdPipeline({ + matchStage, + filter, + page, + rowsPerPage, + field, + order, + limit, + type, + }) + ); + const monitors = queryResult[0]?.monitors; + const count = queryResult[0]?.count; + const normalizedFilteredMonitors = monitors.map((monitor) => { + if (!monitor.checks) { + return monitor; + } + monitor.checks = NormalizeData(monitor.checks, 10, 100); + return monitor; + }); + return { count, monitors: normalizedFilteredMonitors }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getMonitorsWithChecksByTeamId"; + throw error; + } +}; + +/** + * Create a monitor + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const createMonitor = async (req, res) => { + try { + const monitor = new Monitor({ ...req.body }); + // Remove notifications fom monitor as they aren't needed here + monitor.notifications = undefined; + await monitor.save(); + return monitor; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createMonitor"; + throw error; + } +}; + +/** + * Create bulk monitors + * @async + * @param {Express.Request} req + * @returns {Promise} + * @throws {Error} + */ +const createBulkMonitors = async (req) => { + try { + const monitors = req.body.map( + (item) => new Monitor({ ...item, notifications: undefined }) + ); + await Monitor.bulkSave(monitors); + return monitors; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createBulkMonitors"; + throw error; + } +}; + +/** + * Delete a monitor by ID + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const deleteMonitor = async (req, res) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + + const monitorId = req.params.monitorId; + try { + const monitor = await Monitor.findByIdAndDelete(monitorId); + if (!monitor) { + throw new Error(stringService.getDbFindMonitorById(monitorId)); + } + return monitor; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteMonitor"; + throw error; + } +}; + +/** + * DELETE ALL MONITORS (TEMP) + */ + +const deleteAllMonitors = async (teamId) => { + try { + const monitors = await Monitor.find({ teamId }); + const { deletedCount } = await Monitor.deleteMany({ teamId }); + + return { monitors, deletedCount }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteAllMonitors"; + throw error; + } +}; + +/** + * Delete all monitors associated with a user ID + * @async + * @param {string} userId - The ID of the user whose monitors are to be deleted. + * @returns {Promise} A promise that resolves when the operation is complete. + */ +const deleteMonitorsByUserId = async (userId) => { + try { + const result = await Monitor.deleteMany({ userId: userId }); + return result; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteMonitorsByUserId"; + throw error; + } +}; + +/** + * Edit a monitor by ID + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const editMonitor = async (candidateId, candidateMonitor) => { + candidateMonitor.notifications = undefined; + + try { + const editedMonitor = await Monitor.findByIdAndUpdate(candidateId, candidateMonitor, { + new: true, + }); + return editedMonitor; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "editMonitor"; + throw error; + } +}; + +const addDemoMonitors = async (userId, teamId) => { + try { + const demoMonitorsToInsert = demoMonitors.map((monitor) => { + return { + userId, + teamId, + name: monitor.name, + description: monitor.name, + type: "http", + url: monitor.url, + interval: 60000, + }; + }); + const insertedMonitors = await Monitor.insertMany(demoMonitorsToInsert); + return insertedMonitors; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "addDemoMonitors"; + throw error; + } +}; + +export { + getAllMonitors, + getAllMonitorsWithUptimeStats, + getMonitorStatsById, + getMonitorById, + getMonitorsByTeamId, + getMonitorsAndSummaryByTeamId, + getMonitorsWithChecksByTeamId, + getUptimeDetailsById, + getDistributedUptimeDetailsById, + createMonitor, + createBulkMonitors, + deleteMonitor, + deleteAllMonitors, + deleteMonitorsByUserId, + editMonitor, + addDemoMonitors, + getHardwareDetailsById, +}; + +// Helper functions +export { + calculateUptimeDuration, + getLastChecked, + getLatestResponseTime, + getAverageResponseTime, + getUptimePercentage, + getIncidents, + getDateRange, + getMonitorChecks, + processChecksForDisplay, + groupChecksByTime, + calculateGroupStats, +}; + +// limit 25 +// page 1 +// rowsPerPage 25 +// filter undefined +// field name +// order asc +// skip 25 +// sort { name: 1 } +// filteredMonitors [] + +// limit 25 +// page NaN +// rowsPerPage 25 +// filter undefined +// field name +// order asc +// skip 0 +// sort { name: 1 } diff --git a/server/db/mongo/modules/monitorModuleQueries.js b/server/db/mongo/modules/monitorModuleQueries.js new file mode 100755 index 000000000..0ffaaf6f1 --- /dev/null +++ b/server/db/mongo/modules/monitorModuleQueries.js @@ -0,0 +1,1043 @@ +import { ObjectId } from "mongodb"; + +const buildUptimeDetailsPipeline = (monitorId, dates, dateString) => { + return [ + { + $match: { + monitorId: ObjectId.createFromHexString(monitorId), + createdAt: { $gte: dates.start, $lte: dates.end }, + }, + }, + { + $sort: { + createdAt: 1, + }, + }, + { + $facet: { + // For the response time chart, should return checks for date window + // Grouped by: {day: hour}, {week: day}, {month: day} + uptimePercentage: [ + { + $group: { + _id: null, + upChecks: { + $sum: { $cond: [{ $eq: ["$status", true] }, 1, 0] }, + }, + totalChecks: { $sum: 1 }, + }, + }, + { + $project: { + _id: 0, + percentage: { + $cond: [ + { $eq: ["$totalChecks", 0] }, + 0, + { $divide: ["$upChecks", "$totalChecks"] }, + ], + }, + }, + }, + ], + groupedAvgResponseTime: [ + { + $group: { + _id: null, + avgResponseTime: { + $avg: "$responseTime", + }, + }, + }, + ], + groupedChecks: [ + { + $group: { + _id: { + $dateToString: { + format: dateString, + date: "$createdAt", + }, + }, + avgResponseTime: { + $avg: "$responseTime", + }, + totalChecks: { + $sum: 1, + }, + }, + }, + { + $sort: { + _id: 1, + }, + }, + ], + // Up checks grouped by: {day: hour}, {week: day}, {month: day} + groupedUpChecks: [ + { + $match: { + status: true, + }, + }, + { + $group: { + _id: { + $dateToString: { + format: dateString, + date: "$createdAt", + }, + }, + totalChecks: { + $sum: 1, + }, + avgResponseTime: { + $avg: "$responseTime", + }, + }, + }, + { + $sort: { _id: 1 }, + }, + ], + // Down checks grouped by: {day: hour}, {week: day}, {month: day} for the date window + groupedDownChecks: [ + { + $match: { + status: false, + }, + }, + { + $group: { + _id: { + $dateToString: { + format: dateString, + date: "$createdAt", + }, + }, + totalChecks: { + $sum: 1, + }, + avgResponseTime: { + $avg: "$responseTime", + }, + }, + }, + { + $sort: { _id: 1 }, + }, + ], + }, + }, + { + $lookup: { + from: "monitors", + let: { monitor_id: { $toObjectId: monitorId } }, + pipeline: [ + { + $match: { + $expr: { $eq: ["$_id", "$$monitor_id"] }, + }, + }, + { + $project: { + _id: 1, + name: 1, + status: 1, + interval: 1, + type: 1, + url: 1, + isActive: 1, + }, + }, + ], + as: "monitor", + }, + }, + { + $project: { + groupedAvgResponseTime: { + $arrayElemAt: ["$groupedAvgResponseTime.avgResponseTime", 0], + }, + + groupedChecks: "$groupedChecks", + groupedUpChecks: "$groupedUpChecks", + groupedDownChecks: "$groupedDownChecks", + groupedUptimePercentage: { $arrayElemAt: ["$uptimePercentage.percentage", 0] }, + monitor: { $arrayElemAt: ["$monitor", 0] }, + }, + }, + ]; +}; + +const buildHardwareDetailsPipeline = (monitor, dates, dateString) => { + return [ + { + $match: { + monitorId: monitor._id, + createdAt: { $gte: dates.start, $lte: dates.end }, + }, + }, + { + $sort: { + createdAt: 1, + }, + }, + { + $facet: { + aggregateData: [ + { + $group: { + _id: null, + latestCheck: { + $last: "$$ROOT", + }, + totalChecks: { + $sum: 1, + }, + }, + }, + ], + upChecks: [ + { + $match: { + status: true, + }, + }, + { + $group: { + _id: null, + totalChecks: { + $sum: 1, + }, + }, + }, + ], + checks: [ + { + $limit: 1, + }, + { + $project: { + diskCount: { + $size: "$disk", + }, + }, + }, + { + $lookup: { + from: "hardwarechecks", + let: { + diskCount: "$diskCount", + }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$monitorId", monitor._id] }, + { $gte: ["$createdAt", dates.start] }, + { $lte: ["$createdAt", dates.end] }, + ], + }, + }, + }, + { + $group: { + _id: { + $dateToString: { + format: dateString, + date: "$createdAt", + }, + }, + avgCpuUsage: { + $avg: "$cpu.usage_percent", + }, + avgMemoryUsage: { + $avg: "$memory.usage_percent", + }, + avgTemperatures: { + $push: { + $ifNull: ["$cpu.temperature", [0]], + }, + }, + disks: { + $push: "$disk", + }, + }, + }, + { + $project: { + _id: 1, + avgCpuUsage: 1, + avgMemoryUsage: 1, + avgTemperature: { + $map: { + input: { + $range: [ + 0, + { + $size: { + // Handle null temperatures array + $ifNull: [ + { $arrayElemAt: ["$avgTemperatures", 0] }, + [0], // Default to single-element array if null + ], + }, + }, + ], + }, + as: "index", + in: { + $avg: { + $map: { + input: "$avgTemperatures", + as: "tempArray", + in: { + $ifNull: [ + { $arrayElemAt: ["$$tempArray", "$$index"] }, + 0, // Default to 0 if element is null + ], + }, + }, + }, + }, + }, + }, + disks: { + $map: { + input: { + $range: [0, "$$diskCount"], + }, + as: "diskIndex", + in: { + name: { + $concat: [ + "disk", + { + $toString: "$$diskIndex", + }, + ], + }, + readSpeed: { + $avg: { + $map: { + input: "$disks", + as: "diskArray", + in: { + $arrayElemAt: [ + "$$diskArray.read_speed_bytes", + "$$diskIndex", + ], + }, + }, + }, + }, + writeSpeed: { + $avg: { + $map: { + input: "$disks", + as: "diskArray", + in: { + $arrayElemAt: [ + "$$diskArray.write_speed_bytes", + "$$diskIndex", + ], + }, + }, + }, + }, + totalBytes: { + $avg: { + $map: { + input: "$disks", + as: "diskArray", + in: { + $arrayElemAt: [ + "$$diskArray.total_bytes", + "$$diskIndex", + ], + }, + }, + }, + }, + freeBytes: { + $avg: { + $map: { + input: "$disks", + as: "diskArray", + in: { + $arrayElemAt: ["$$diskArray.free_bytes", "$$diskIndex"], + }, + }, + }, + }, + usagePercent: { + $avg: { + $map: { + input: "$disks", + as: "diskArray", + in: { + $arrayElemAt: [ + "$$diskArray.usage_percent", + "$$diskIndex", + ], + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + as: "hourlyStats", + }, + }, + { + $unwind: "$hourlyStats", + }, + { + $replaceRoot: { + newRoot: "$hourlyStats", + }, + }, + ], + }, + }, + { + $project: { + aggregateData: { + $arrayElemAt: ["$aggregateData", 0], + }, + upChecks: { + $arrayElemAt: ["$upChecks", 0], + }, + checks: { + $sortArray: { + input: "$checks", + sortBy: { _id: 1 }, + }, + }, + }, + }, + ]; +}; + +const buildMonitorStatsPipeline = (monitor) => { + return [ + { + $match: { + monitorId: monitor._id, + }, + }, + { + $project: { + avgResponseTime: 1, + uptimePercentage: 1, + totalChecks: 1, + timeSinceLastCheck: { + $subtract: [Date.now(), "$lastCheckTimestamp"], + }, + lastCheckTimestamp: 1, + uptBurnt: { $toString: "$uptBurnt" }, + }, + }, + ]; +}; + +const buildMonitorSummaryByTeamIdPipeline = ({ matchStage }) => { + return [ + { $match: matchStage }, + { + $group: { + _id: null, + totalMonitors: { $sum: 1 }, + upMonitors: { + $sum: { + $cond: [{ $eq: ["$status", true] }, 1, 0], + }, + }, + downMonitors: { + $sum: { + $cond: [{ $eq: ["$status", false] }, 1, 0], + }, + }, + pausedMonitors: { + $sum: { + $cond: [{ $eq: ["$isActive", false] }, 1, 0], + }, + }, + }, + }, + { + $project: { + _id: 0, + }, + }, + ]; +}; + +const buildMonitorsByTeamIdPipeline = ({ matchStage, field, order }) => { + const sort = { [field]: order === "asc" ? 1 : -1 }; + + return [ + { $match: matchStage }, + { $sort: sort }, + { + $project: { + _id: 1, + name: 1, + type: 1, + }, + }, + ]; +}; + +const buildMonitorsAndSummaryByTeamIdPipeline = ({ matchStage }) => { + return [ + { $match: matchStage }, + { + $facet: { + summary: [ + { + $group: { + _id: null, + totalMonitors: { $sum: 1 }, + upMonitors: { + $sum: { + $cond: [{ $eq: ["$status", true] }, 1, 0], + }, + }, + downMonitors: { + $sum: { + $cond: [{ $eq: ["$status", false] }, 1, 0], + }, + }, + pausedMonitors: { + $sum: { + $cond: [{ $eq: ["$isActive", false] }, 1, 0], + }, + }, + }, + }, + { + $project: { + _id: 0, + }, + }, + ], + monitors: [ + { $sort: { name: 1 } }, + { + $project: { + _id: 1, + name: 1, + type: 1, + }, + }, + ], + }, + }, + { + $project: { + summary: { $arrayElemAt: ["$summary", 0] }, + monitors: 1, + }, + }, + ]; +}; + +const buildMonitorsWithChecksByTeamIdPipeline = ({ + matchStage, + filter, + page, + rowsPerPage, + field, + order, + limit, + type, +}) => { + const skip = page && rowsPerPage ? page * rowsPerPage : 0; + const sort = { [field]: order === "asc" ? 1 : -1 }; + const limitStage = rowsPerPage ? [{ $limit: rowsPerPage }] : []; + + // Match name + if (typeof filter !== "undefined" && field === "name") { + matchStage.$or = [ + { name: { $regex: filter, $options: "i" } }, + { url: { $regex: filter, $options: "i" } }, + ]; + } + + // Match isActive + if (typeof filter !== "undefined" && field === "isActive") { + matchStage.isActive = filter === "true" ? true : false; + } + + if (typeof filter !== "undefined" && field === "status") { + matchStage.status = filter === "true" ? true : false; + } + + // Match type + if (typeof filter !== "undefined" && field === "type") { + matchStage.type = filter; + } + + const monitorsPipeline = [ + { $sort: sort }, + { $skip: skip }, + ...limitStage, + { + $project: { + _id: 1, + name: 1, + description: 1, + type: 1, + url: 1, + isActive: 1, + createdAt: 1, + updatedAt: 1, + uptimePercentage: 1, + status: 1, + }, + }, + ]; + + // Add checks + if (limit) { + let checksCollection = "checks"; + if (type === "pagespeed") { + checksCollection = "pagespeedchecks"; + } else if (type === "hardware") { + checksCollection = "hardwarechecks"; + } else if (type === "distributed_http" || type === "distributed_test") { + checksCollection = "distributeduptimechecks"; + } + monitorsPipeline.push({ + $lookup: { + from: checksCollection, + let: { monitorId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { $eq: ["$monitorId", "$$monitorId"] }, + }, + }, + { $sort: { updatedAt: -1 } }, + { $limit: limit }, + { + $project: { + _id: 1, + status: 1, + responseTime: 1, + statusCode: 1, + createdAt: 1, + updatedAt: 1, + originalResponseTime: 1, + }, + }, + ], + as: "checks", + }, + }); + } + + const pipeline = [ + { $match: matchStage }, + { + $facet: { + count: [{ $count: "monitorsCount" }], + monitors: monitorsPipeline, + }, + }, + { + $project: { + count: { $arrayElemAt: ["$count", 0] }, + monitors: 1, + }, + }, + ]; + return pipeline; +}; + +const buildFilteredMonitorsByTeamIdPipeline = ({ + matchStage, + filter, + page, + rowsPerPage, + field, + order, + limit, + type, +}) => { + const skip = page && rowsPerPage ? page * rowsPerPage : 0; + const sort = { [field]: order === "asc" ? 1 : -1 }; + const limitStage = rowsPerPage ? [{ $limit: rowsPerPage }] : []; + + if (typeof filter !== "undefined" && field === "name") { + matchStage.$or = [ + { name: { $regex: filter, $options: "i" } }, + { url: { $regex: filter, $options: "i" } }, + ]; + } + + if (typeof filter !== "undefined" && field === "status") { + matchStage.status = filter === "true"; + } + + const pipeline = [ + { $match: matchStage }, + { $sort: sort }, + { $skip: skip }, + ...limitStage, + ]; + + // Add checks + if (limit) { + let checksCollection = "checks"; + if (type === "pagespeed") { + checksCollection = "pagespeedchecks"; + } else if (type === "hardware") { + checksCollection = "hardwarechecks"; + } else if (type === "distributed_http" || type === "distributed_test") { + checksCollection = "distributeduptimechecks"; + } + pipeline.push({ + $lookup: { + from: checksCollection, + let: { monitorId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { $eq: ["$monitorId", "$$monitorId"] }, + }, + }, + { $sort: { createdAt: -1 } }, + { $limit: limit }, + ], + as: "checks", + }, + }); + } + + return pipeline; +}; + +const buildGetMonitorsByTeamIdPipeline = (req) => { + let { limit, type, page, rowsPerPage, filter, field, order } = req.query; + + limit = parseInt(limit); + page = parseInt(page); + rowsPerPage = parseInt(rowsPerPage); + if (field === undefined) { + field = "name"; + order = "asc"; + } + // Build the match stage + const matchStage = { teamId: ObjectId.createFromHexString(req.params.teamId) }; + if (type !== undefined) { + matchStage.type = Array.isArray(type) ? { $in: type } : type; + } + + const skip = page && rowsPerPage ? page * rowsPerPage : 0; + const sort = { [field]: order === "asc" ? 1 : -1 }; + return [ + { $match: matchStage }, + { + $facet: { + summary: [ + { + $group: { + _id: null, + totalMonitors: { $sum: 1 }, + upMonitors: { + $sum: { + $cond: [{ $eq: ["$status", true] }, 1, 0], + }, + }, + downMonitors: { + $sum: { + $cond: [{ $eq: ["$status", false] }, 1, 0], + }, + }, + pausedMonitors: { + $sum: { + $cond: [{ $eq: ["$isActive", false] }, 1, 0], + }, + }, + }, + }, + { + $project: { + _id: 0, + }, + }, + ], + monitors: [ + { $sort: sort }, + { + $project: { + _id: 1, + name: 1, + }, + }, + ], + filteredMonitors: [ + ...(filter !== undefined + ? [ + { + $match: { + $or: [ + { name: { $regex: filter, $options: "i" } }, + { url: { $regex: filter, $options: "i" } }, + ], + }, + }, + ] + : []), + { $sort: sort }, + { $skip: skip }, + ...(rowsPerPage ? [{ $limit: rowsPerPage }] : []), + ...(limit + ? [ + { + $lookup: { + from: "checks", + let: { monitorId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { $eq: ["$monitorId", "$$monitorId"] }, + }, + }, + { $sort: { createdAt: -1 } }, + ...(limit ? [{ $limit: limit }] : []), + ], + as: "standardchecks", + }, + }, + ] + : []), + ...(limit + ? [ + { + $lookup: { + from: "pagespeedchecks", + let: { monitorId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { $eq: ["$monitorId", "$$monitorId"] }, + }, + }, + { $sort: { createdAt: -1 } }, + ...(limit ? [{ $limit: limit }] : []), + ], + as: "pagespeedchecks", + }, + }, + ] + : []), + ...(limit + ? [ + { + $lookup: { + from: "hardwarechecks", + let: { monitorId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { $eq: ["$monitorId", "$$monitorId"] }, + }, + }, + { $sort: { createdAt: -1 } }, + ...(limit ? [{ $limit: limit }] : []), + ], + as: "hardwarechecks", + }, + }, + ] + : []), + ...(limit + ? [ + { + $lookup: { + from: "distributeduptimechecks", + let: { monitorId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { $eq: ["$monitorId", "$$monitorId"] }, + }, + }, + { $sort: { createdAt: -1 } }, + ...(limit ? [{ $limit: limit }] : []), + ], + as: "distributeduptimechecks", + }, + }, + ] + : []), + + { + $addFields: { + checks: { + $switch: { + branches: [ + { + case: { $in: ["$type", ["http", "ping", "docker", "port"]] }, + then: "$standardchecks", + }, + { + case: { $eq: ["$type", "pagespeed"] }, + then: "$pagespeedchecks", + }, + { + case: { $eq: ["$type", "hardware"] }, + then: "$hardwarechecks", + }, + { + case: { $eq: ["$type", "distributed_http"] }, + then: "$distributeduptimechecks", + }, + { + case: { $eq: ["$type", "distributed_test"] }, + then: "$distributeduptimechecks", + }, + ], + default: [], + }, + }, + }, + }, + { + $project: { + standardchecks: 0, + pagespeedchecks: 0, + hardwarechecks: 0, + }, + }, + ], + }, + }, + { + $project: { + summary: { $arrayElemAt: ["$summary", 0] }, + filteredMonitors: 1, + monitors: 1, + }, + }, + ]; +}; + +const buildDePINDetailsByDateRange = (monitor, dates, dateString) => { + return [ + { + $match: { + monitorId: monitor._id, + updatedAt: { $gte: dates.start, $lte: dates.end }, + }, + }, + { + $project: { + _id: 0, + city: 1, + updatedAt: 1, + "location.lat": 1, + "location.lng": 1, + responseTime: 1, + }, + }, + { + $facet: { + groupedMapChecks: [ + { + $group: { + _id: { + city: "$city", + lat: "$location.lat", + lng: "$location.lng", + }, + + avgResponseTime: { + $avg: "$responseTime", + }, + }, + }, + ], + groupedChecks: [ + { + $group: { + _id: { + date: { + $dateToString: { + format: dateString, + date: "$updatedAt", + }, + }, + }, + avgResponseTime: { + $avg: "$responseTime", + }, + }, + }, + { + $sort: { + "_id.date": 1, + }, + }, + ], + }, + }, + { + $project: { + groupedMapChecks: "$groupedMapChecks", + groupedChecks: "$groupedChecks", + }, + }, + ]; +}; + +const buildDePINLatestChecks = (monitor) => { + return [ + { + $match: { + monitorId: monitor._id, + }, + }, + { + $sort: { updatedAt: -1 }, + }, + { + $limit: 5, + }, + { + $project: { + responseTime: 1, + city: 1, + countryCode: 1, + uptBurnt: { + $toString: { + $ifNull: ["$uptBurnt", 0], + }, + }, + }, + }, + ]; +}; + +export { + buildUptimeDetailsPipeline, + buildHardwareDetailsPipeline, + buildMonitorStatsPipeline, + buildGetMonitorsByTeamIdPipeline, + buildMonitorSummaryByTeamIdPipeline, + buildMonitorsByTeamIdPipeline, + buildMonitorsAndSummaryByTeamIdPipeline, + buildMonitorsWithChecksByTeamIdPipeline, + buildFilteredMonitorsByTeamIdPipeline, + buildDePINDetailsByDateRange, + buildDePINLatestChecks, +}; diff --git a/server/db/mongo/modules/notificationModule.js b/server/db/mongo/modules/notificationModule.js new file mode 100755 index 000000000..aab2d03bd --- /dev/null +++ b/server/db/mongo/modules/notificationModule.js @@ -0,0 +1,56 @@ +import Notification from "../../models/Notification.js"; +const SERVICE_NAME = "notificationModule"; +/** + * Creates a new notification. + * @param {Object} notificationData - The data for the new notification. + * @param {mongoose.Types.ObjectId} notificationData.monitorId - The ID of the monitor. + * @param {string} notificationData.type - The type of the notification (e.g., "email", "sms"). + * @param {string} [notificationData.address] - The address for the notification (if applicable). + * @param {string} [notificationData.phone] - The phone number for the notification (if applicable). + * @returns {Promise} The created notification. + * @throws Will throw an error if the notification cannot be created. + */ +const createNotification = async (notificationData) => { + try { + const notification = await new Notification({ ...notificationData }).save(); + return notification; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createNotification"; + throw error; + } +}; + +/** + * Retrieves notifications by monitor ID. + * @param {mongoose.Types.ObjectId} monitorId - The ID of the monitor. + * @returns {Promise>} An array of notifications. + * @throws Will throw an error if the notifications cannot be retrieved. + */ +const getNotificationsByMonitorId = async (monitorId) => { + try { + const notifications = await Notification.find({ monitorId }); + return notifications; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getNotificationsByMonitorId"; + throw error; + } +}; + +const deleteNotificationsByMonitorId = async (monitorId) => { + try { + const result = await Notification.deleteMany({ monitorId }); + return result.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteNotificationsByMonitorId"; + throw error; + } +}; + +export { + createNotification, + getNotificationsByMonitorId, + deleteNotificationsByMonitorId, +}; diff --git a/server/db/mongo/modules/pageSpeedCheckModule.js b/server/db/mongo/modules/pageSpeedCheckModule.js new file mode 100755 index 000000000..f634fae27 --- /dev/null +++ b/server/db/mongo/modules/pageSpeedCheckModule.js @@ -0,0 +1,57 @@ +import PageSpeedCheck from "../../models/PageSpeedCheck.js"; +const SERVICE_NAME = "pageSpeedCheckModule"; +/** + * Create a PageSpeed check for a monitor + * @async + * @param {Object} pageSpeedCheckData + * @param {string} pageSpeedCheckData.monitorId + * @param {number} pageSpeedCheckData.accessibility + * @param {number} pageSpeedCheckData.bestPractices + * @param {number} pageSpeedCheckData.seo + * @param {number} pageSpeedCheckData.performance + * @returns {Promise} + * @throws {Error} + */ +const createPageSpeedCheck = async (pageSpeedCheckData) => { + try { + const pageSpeedCheck = await new PageSpeedCheck({ + ...pageSpeedCheckData, + }).save(); + return pageSpeedCheck; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createPageSpeedCheck"; + throw error; + } +}; +const createPageSpeedChecks = async (pageSpeedChecks) => { + try { + await PageSpeedCheck.insertMany(pageSpeedChecks); + return true; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createPageSpeedCheck"; + throw error; + } +}; + +/** + * Delete all PageSpeed checks for a monitor + * @async + * @param {string} monitorId + * @returns {number} + * @throws {Error} + */ + +const deletePageSpeedChecksByMonitorId = async (monitorId) => { + try { + const result = await PageSpeedCheck.deleteMany({ monitorId }); + return result.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deletePageSpeedChecksByMonitorId"; + throw error; + } +}; + +export { createPageSpeedCheck, createPageSpeedChecks, deletePageSpeedChecksByMonitorId }; diff --git a/server/db/mongo/modules/recoveryModule.js b/server/db/mongo/modules/recoveryModule.js new file mode 100755 index 000000000..3b39e847c --- /dev/null +++ b/server/db/mongo/modules/recoveryModule.js @@ -0,0 +1,88 @@ +import UserModel from "../../models/User.js"; +import RecoveryToken from "../../models/RecoveryToken.js"; +import crypto from "crypto"; +import serviceRegistry from "../../../service/serviceRegistry.js"; +import StringService from "../../../service/stringService.js"; + +const SERVICE_NAME = "recoveryModule"; + +/** + * Request a recovery token + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const requestRecoveryToken = async (req, res) => { + try { + // Delete any existing tokens + await RecoveryToken.deleteMany({ email: req.body.email }); + let recoveryToken = new RecoveryToken({ + email: req.body.email, + token: crypto.randomBytes(32).toString("hex"), + }); + await recoveryToken.save(); + return recoveryToken; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "requestRecoveryToken"; + throw error; + } +}; + +const validateRecoveryToken = async (req, res) => { + const stringService = serviceRegistry.get(StringService.SERVICE_NAME); + try { + const candidateToken = req.body.recoveryToken; + const recoveryToken = await RecoveryToken.findOne({ + token: candidateToken, + }); + if (recoveryToken !== null) { + return recoveryToken; + } else { + throw new Error(stringService.dbTokenNotFound); + } + } catch (error) { + error.service = SERVICE_NAME; + error.method = "validateRecoveryToken"; + throw error; + } +}; + +const resetPassword = async (req, res) => { + const stringService = serviceRegistry.get(StringService.SERVICE_NAME); + try { + const newPassword = req.body.password; + + // Validate token again + const recoveryToken = await validateRecoveryToken(req, res); + const user = await UserModel.findOne({ email: recoveryToken.email }); + + if (user === null) { + throw new Error(stringService.dbUserNotFound); + } + + const match = await user.comparePassword(newPassword); + if (match === true) { + throw new Error(stringService.dbResetPasswordBadMatch); + } + + user.password = newPassword; + await user.save(); + await RecoveryToken.deleteMany({ email: recoveryToken.email }); + // Fetch the user again without the password + const userWithoutPassword = await UserModel.findOne({ + email: recoveryToken.email, + }) + .select("-password") + .select("-profileImage"); + return userWithoutPassword; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "resetPassword"; + throw error; + } +}; + +export { requestRecoveryToken, validateRecoveryToken, resetPassword }; diff --git a/server/db/mongo/modules/settingsModule.js b/server/db/mongo/modules/settingsModule.js new file mode 100755 index 000000000..3b5f68e19 --- /dev/null +++ b/server/db/mongo/modules/settingsModule.js @@ -0,0 +1,30 @@ +import AppSettings from "../../models/AppSettings.js"; +const SERVICE_NAME = "SettingsModule"; + +const getAppSettings = async () => { + try { + const settings = AppSettings.findOne(); + return settings; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getSettings"; + throw error; + } +}; + +const updateAppSettings = async (newSettings) => { + try { + const settings = await AppSettings.findOneAndUpdate( + {}, + { $set: newSettings }, + { new: true } + ); + return settings; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "updateAppSettings"; + throw error; + } +}; + +export { getAppSettings, updateAppSettings }; diff --git a/server/db/mongo/modules/statusPageModule.js b/server/db/mongo/modules/statusPageModule.js new file mode 100755 index 000000000..9f8952c4e --- /dev/null +++ b/server/db/mongo/modules/statusPageModule.js @@ -0,0 +1,310 @@ +import StatusPage from "../../models/StatusPage.js"; +import Monitor from "../../models/Monitor.js"; +import { NormalizeData } from "../../../utils/dataUtils.js"; +import ServiceRegistry from "../../../service/serviceRegistry.js"; +import StringService from "../../../service/stringService.js"; + +const SERVICE_NAME = "statusPageModule"; + +const createStatusPage = async (statusPageData, image) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + + try { + const statusPage = new StatusPage({ ...statusPageData }); + if (image) { + statusPage.logo = { + data: image.buffer, + contentType: image.mimetype, + }; + } + await statusPage.save(); + return statusPage; + } catch (error) { + if (error?.code === 11000) { + // Handle duplicate URL errors + error.status = 400; + error.message = stringService.statusPageUrlNotUnique; + } + error.service = SERVICE_NAME; + error.method = "createStatusPage"; + throw error; + } +}; + +const updateStatusPage = async (statusPageData, image) => { + try { + if (image) { + statusPageData.logo = { + data: image.buffer, + contentType: image.mimetype, + }; + } + + if (statusPageData.deleteSubmonitors === "true") { + statusPageData.subMonitors = []; + } + const statusPage = await StatusPage.findOneAndUpdate( + { url: statusPageData.url }, + statusPageData, + { + new: true, + } + ); + + return statusPage; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "updateStatusPage"; + throw error; + } +}; + +const getDistributedStatusPageByUrl = async ({ url, daysToShow = 30 }) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + try { + const statusPage = await StatusPage.findOne({ url }).lean(); + + if (!statusPage) { + const error = new Error(stringService.statusPageNotFound); + error.status = 404; + throw error; + } + + // No sub monitors, return status page + if (statusPage.subMonitors.length === 0) { + return statusPage; + } + // Sub monitors, return status page with sub monitors + const daysAgo = new Date(); + daysAgo.setDate(daysAgo.getDate() - daysToShow); + + const subMonitors = await Monitor.aggregate([ + { $match: { _id: { $in: statusPage.subMonitors } } }, + { + $addFields: { + orderIndex: { $indexOfArray: [statusPage.subMonitors, "$_id"] }, + }, + }, + + // Return 30 days of checks by default + { + $lookup: { + from: "checks", + let: { monitorId: "$_id" }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$monitorId", "$$monitorId"] }, + { $gte: ["$updatedAt", daysAgo] }, + ], + }, + }, + }, + { + $group: { + _id: { + $dateToString: { format: "%Y-%m-%d", date: "$updatedAt" }, + }, + responseTime: { + $avg: "$responseTime", + }, + trueCount: { + $sum: { + $cond: [{ $eq: ["$status", true] }, 1, 0], + }, + }, + totalCount: { + $sum: 1, + }, + }, + }, + { + $project: { + _id: 1, + responseTime: 1, + upPercentage: { + $cond: [ + { $eq: ["$totalCount", 0] }, + 0, + { $multiply: [{ $divide: ["$trueCount", "$totalCount"] }, 100] }, + ], + }, + }, + }, + { + $sort: { _id: -1 }, + }, + ], + as: "checks", + }, + }, + { $sort: { orderIndex: 1 } }, + { $project: { orderIndex: 0 } }, + ]); + + const normalizedSubMonitors = subMonitors.map((monitor) => { + return { + ...monitor, + checks: NormalizeData(monitor.checks, 10, 100), + }; + }); + return { ...statusPage, subMonitors: normalizedSubMonitors }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getDistributedStatusPageByUrl"; + throw error; + } +}; + +const getStatusPageByUrl = async (url, type) => { + // TODO This is deprecated, can remove and have controller call getStatusPage + try { + return getStatusPage(url); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getStatusPageByUrl"; + throw error; + } +}; + +const getStatusPagesByTeamId = async (teamId) => { + try { + const statusPages = await StatusPage.find({ teamId }); + return statusPages; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getStatusPagesByTeamId"; + throw error; + } +}; + +const getStatusPage = async (url) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + + try { + const statusPageQuery = await StatusPage.aggregate([ + { $match: { url: url } }, + { + $set: { + originalMonitors: "$monitors", + }, + }, + { + $lookup: { + from: "monitors", + localField: "monitors", + foreignField: "_id", + as: "monitors", + }, + }, + { + $unwind: "$monitors", + }, + { + $lookup: { + from: "checks", + let: { monitorId: "$monitors._id" }, + pipeline: [ + { + $match: { + $expr: { $eq: ["$monitorId", "$$monitorId"] }, + }, + }, + { $sort: { createdAt: -1 } }, + { $limit: 25 }, + ], + as: "monitors.checks", + }, + }, + { + $addFields: { + "monitors.orderIndex": { + $indexOfArray: ["$originalMonitors", "$monitors._id"], + }, + }, + }, + { + $group: { + _id: "$_id", + statusPage: { $first: "$$ROOT" }, + monitors: { $push: "$monitors" }, + }, + }, + { + $project: { + statusPage: { + _id: 1, + color: 1, + companyName: 1, + isPublished: 1, + logo: 1, + originalMonitors: 1, + showCharts: 1, + showUptimePercentage: 1, + timezone: 1, + url: 1, + }, + monitors: { + $sortArray: { + input: "$monitors", + sortBy: { orderIndex: 1 }, + }, + }, + }, + }, + ]); + if (!statusPageQuery.length) { + const error = new Error(stringService.statusPageNotFound); + error.status = 404; + throw error; + } + + const { statusPage, monitors } = statusPageQuery[0]; + + const normalizedMonitors = monitors.map((monitor) => { + return { + ...monitor, + checks: NormalizeData(monitor.checks, 10, 100), + }; + }); + + return { statusPage, monitors: normalizedMonitors }; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getStatusPageByUrl"; + throw error; + } +}; + +const deleteStatusPage = async (url) => { + try { + await StatusPage.deleteOne({ url }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteStatusPage"; + throw error; + } +}; + +const deleteStatusPagesByMonitorId = async (monitorId) => { + try { + await StatusPage.deleteMany({ monitors: { $in: [monitorId] } }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteStatusPageByMonitorId"; + throw error; + } +}; + +export { + createStatusPage, + updateStatusPage, + getStatusPagesByTeamId, + getStatusPage, + getStatusPageByUrl, + getDistributedStatusPageByUrl, + deleteStatusPage, + deleteStatusPagesByMonitorId, +}; diff --git a/server/db/mongo/modules/userModule.js b/server/db/mongo/modules/userModule.js new file mode 100755 index 000000000..4069be8c6 --- /dev/null +++ b/server/db/mongo/modules/userModule.js @@ -0,0 +1,232 @@ +import UserModel from "../../models/User.js"; +import TeamModel from "../../models/Team.js"; +import { GenerateAvatarImage } from "../../../utils/imageProcessing.js"; + +const DUPLICATE_KEY_CODE = 11000; // MongoDB error code for duplicate key +import { ParseBoolean } from "../../../utils/utils.js"; +import ServiceRegistry from "../../../service/serviceRegistry.js"; +import StringService from "../../../service/stringService.js"; +const SERVICE_NAME = "userModule"; + +/** + * Insert a User + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const insertUser = async ( + userData, + imageFile, + generateAvatarImage = GenerateAvatarImage +) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + try { + if (imageFile) { + // 1. Save the full size image + userData.profileImage = { + data: imageFile.buffer, + contentType: imageFile.mimetype, + }; + + // 2. Get the avatar sized image + const avatar = await generateAvatarImage(imageFile); + userData.avatarImage = avatar; + } + + // Handle creating team if superadmin + if (userData.role.includes("superadmin")) { + const team = new TeamModel({ + email: userData.email, + }); + userData.teamId = team._id; + userData.checkTTL = 60 * 60 * 24 * 30; + await team.save(); + } + + const newUser = new UserModel(userData); + await newUser.save(); + return await UserModel.findOne({ _id: newUser._id }) + .select("-password") + .select("-profileImage"); // .select() doesn't work with create, need to save then find + } catch (error) { + if (error.code === DUPLICATE_KEY_CODE) { + error.message = stringService.dbUserExists; + } + error.service = SERVICE_NAME; + error.method = "insertUser"; + throw error; + } +}; + +/** + * Get User by Email + * Gets a user by Email. Not sure if we'll ever need this except for login. + * If not needed except for login, we can move password comparison here + * Throws error if user not found + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const getUserByEmail = async (email) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + + try { + // Need the password to be able to compare, removed .select() + // We can strip the hash before returning the user + const user = await UserModel.findOne({ email: email }).select("-profileImage"); + if (!user) { + throw new Error(stringService.dbUserNotFound); + } + return user; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getUserByEmail"; + throw error; + } +}; + +/** + * Update a user by ID + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ + +const updateUser = async ( + req, + res, + parseBoolean = ParseBoolean, + generateAvatarImage = GenerateAvatarImage +) => { + const candidateUserId = req.params.userId; + try { + const candidateUser = { ...req.body }; + // ****************************************** + // Handle profile image + // ****************************************** + + if (parseBoolean(candidateUser.deleteProfileImage) === true) { + candidateUser.profileImage = null; + candidateUser.avatarImage = null; + } else if (req.file) { + // 1. Save the full size image + candidateUser.profileImage = { + data: req.file.buffer, + contentType: req.file.mimetype, + }; + + // 2. Get the avatar sized image + const avatar = await generateAvatarImage(req.file); + candidateUser.avatarImage = avatar; + } + + // ****************************************** + // End handling profile image + // ****************************************** + + const updatedUser = await UserModel.findByIdAndUpdate( + candidateUserId, + candidateUser, + { new: true } // Returns updated user instead of pre-update user + ) + .select("-password") + .select("-profileImage"); + return updatedUser; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "updateUser"; + throw error; + } +}; + +/** + * Delete a user by ID + * @async + * @param {Express.Request} req + * @param {Express.Response} res + * @returns {Promise} + * @throws {Error} + */ +const deleteUser = async (userId) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + + try { + const deletedUser = await UserModel.findByIdAndDelete(userId); + if (!deletedUser) { + throw new Error(stringService.dbUserNotFound); + } + return deletedUser; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteUser"; + throw error; + } +}; + +/** + * Delete a user by ID + * @async + * @param {string} teamId + * @returns {void} + * @throws {Error} + */ +const deleteTeam = async (teamId) => { + try { + await TeamModel.findByIdAndDelete(teamId); + return true; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteTeam"; + throw error; + } +}; + +const deleteAllOtherUsers = async () => { + try { + await UserModel.deleteMany({ role: { $ne: "superadmin" } }); + return true; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteAllOtherUsers"; + throw error; + } +}; + +const getAllUsers = async (req, res) => { + try { + const users = await UserModel.find().select("-password").select("-profileImage"); + return users; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getAllUsers"; + throw error; + } +}; + +const logoutUser = async (userId) => { + try { + await UserModel.updateOne({ _id: userId }, { $unset: { authToken: null } }); + return true; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "logoutUser"; + throw error; + } +}; + +export { + insertUser, + getUserByEmail, + updateUser, + deleteUser, + deleteTeam, + deleteAllOtherUsers, + getAllUsers, + logoutUser, +}; diff --git a/server/db/mongo/utils/seedDb.js b/server/db/mongo/utils/seedDb.js new file mode 100755 index 000000000..d8317c89b --- /dev/null +++ b/server/db/mongo/utils/seedDb.js @@ -0,0 +1,177 @@ +import Monitor from "../../models/Monitor.js"; +import Check from "../../models/Check.js"; +import DistributedUptimeCheck from "../../models/DistributedUptimeCheck.js"; +import logger from "../../../utils/logger.js"; + +const generateRandomUrl = () => { + const domains = ["example.com", "test.org", "demo.net", "sample.io", "mock.dev"]; + const paths = ["api", "status", "health", "ping", "check"]; + return `https://${domains[Math.floor(Math.random() * domains.length)]}/${paths[Math.floor(Math.random() * paths.length)]}`; +}; + +const generateChecks = (monitorId, teamId, count) => { + const checks = []; + const endTime = new Date(Date.now() - 10 * 60 * 1000); // 10 minutes ago + const startTime = new Date(endTime - count * 60 * 1000); // count minutes before endTime + + for (let i = 0; i < count; i++) { + const timestamp = new Date(startTime.getTime() + i * 60 * 1000); + const status = Math.random() > 0.05; // 95% chance of being up + + checks.push({ + monitorId, + teamId, + status, + responseTime: Math.floor(Math.random() * 1000), // Random response time between 0-1000ms + createdAt: timestamp, + updatedAt: timestamp, + }); + } + + return checks; +}; + +const seedDb = async (userId, teamId) => { + try { + logger.info({ + message: "Deleting all monitors and checks", + service: "seedDb", + method: "seedDb", + }); + await Monitor.deleteMany({}); + await Check.deleteMany({}); + logger.info({ + message: "Adding monitors", + service: "DB", + method: "seedDb", + }); + for (let i = 0; i < 300; i++) { + const monitor = await Monitor.create({ + name: `Monitor ${i}`, + url: generateRandomUrl(), + type: "http", + userId, + teamId, + interval: 60000, + active: false, + }); + logger.info({ + message: `Adding monitor and checks for monitor ${i}`, + service: "DB", + method: "seedDb", + }); + const checks = generateChecks(monitor._id, teamId, 10000); + await Check.insertMany(checks); + } + } catch (error) { + logger.error({ + message: "Error seeding DB", + service: "DB", + method: "seedDb", + stack: error.stack, + }); + } +}; + +const generateDistributedChecks = (monitorId, teamId, count = 2880) => { + const checks = []; + const endTime = new Date(); + const startTime = new Date(endTime - 48 * 60 * 60 * 1000); + + // Sample locations for variety + const locations = [ + { + city: "New York", + countryCode: "US", + continent: "NA", + location: { lat: 40.7128, lng: -74.006 }, + }, + { + city: "London", + countryCode: "GB", + continent: "EU", + location: { lat: 51.5074, lng: -0.1278 }, + }, + { + city: "Singapore", + countryCode: "SG", + continent: "AS", + location: { lat: 1.3521, lng: 103.8198 }, + }, + ]; + + for (let i = 0; i < count; i++) { + const timestamp = new Date(startTime.getTime() + i * 60 * 1000); + const location = locations[Math.floor(Math.random() * locations.length)]; + const status = Math.random() > 0.05; // 95% success rate + + checks.push({ + monitorId, + teamId, + status, + responseTime: Math.floor(Math.random() * 1000), // Random response time between 0-1000ms + first_byte_took: Math.floor(Math.random() * 300000), // 0-300ms + body_read_took: Math.floor(Math.random() * 100000), // 0-100ms + dns_took: Math.floor(Math.random() * 100000), // 0-100ms + conn_took: Math.floor(Math.random() * 200000), // 0-200ms + connect_took: Math.floor(Math.random() * 150000), // 0-150ms + tls_took: Math.floor(Math.random() * 200000), // 0-200ms + location: location.location, + continent: location.continent, + countryCode: location.countryCode, + city: location.city, + uptBurnt: "0.01", // Will be converted to Decimal128 by the schema + createdAt: timestamp, + updatedAt: timestamp, + }); + } + + return checks; +}; + +export const seedDistributedTest = async (userId, teamId) => { + try { + logger.info({ + message: "Deleting all test monitors and checks", + service: "DB", + method: "seedDistributedTest", + }); + + const testMonitors = await Monitor.find({ + type: "distributed_test", + }); + + testMonitors.forEach(async (monitor) => { + await DistributedUptimeCheck.deleteMany({ monitorId: monitor._id }); + await Monitor.deleteOne({ _id: monitor._id }); + }); + + logger.info({ + message: "Adding test monitors and checks", + service: "DB", + method: "seedDistributedTest", + }); + const monitor = await Monitor.create({ + name: "Distributed Test", + url: "https://distributed-test.com", + type: "distributed_test", + userId, + teamId, + interval: 60000, + active: false, + }); + const checks = generateDistributedChecks(monitor._id, teamId, 2800); + await DistributedUptimeCheck.insertMany(checks); + return monitor; + } catch (error) { + logger.error({ + message: "Error seeding distributed test", + service: "DB", + method: "seedDistributedTest", + stack: error.stack, + }); + throw error; + } +}; + +export default seedDb; diff --git a/server/eslint.config.js b/server/eslint.config.js new file mode 100755 index 000000000..13f1fc038 --- /dev/null +++ b/server/eslint.config.js @@ -0,0 +1,30 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import mochaPlugin from "eslint-plugin-mocha"; + +/* +Please do not forget to look at the latest eslint configurations and rules. +ESlint v9 configuration is different than v8. +"https://eslint.org/docs/latest/use/configure/" +*/ + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { + languageOptions: { + globals: { + ...globals.node, // Add Node.js globals + ...globals.chai, // Add Chai globals + }, + ecmaVersion: 2023, + sourceType: "module", + }, + }, + pluginJs.configs.recommended, // Core JS rules + mochaPlugin.configs.flat.recommended, // Mocha rules + { + rules: { + "mocha/max-top-level-suites": "warn", // Warn if there are too many top-level suites instead of failing + }, + }, +]; diff --git a/server/index.js b/server/index.js new file mode 100755 index 000000000..520e5cab2 --- /dev/null +++ b/server/index.js @@ -0,0 +1,364 @@ +import path from "path"; +import fs from "fs"; +import swaggerUi from "swagger-ui-express"; + +import express from "express"; +import helmet from "helmet"; +import cors from "cors"; +import compression from "compression"; +import logger from "./utils/logger.js"; +import { verifyJWT } from "./middleware/verifyJWT.js"; +import { handleErrors } from "./middleware/handleErrors.js"; +import { responseHandler } from "./middleware/responseHandler.js"; +import { fileURLToPath } from "url"; + +import AuthRoutes from "./routes/authRoute.js"; +import AuthController from "./controllers/authController.js"; + +import InviteRoutes from "./routes/inviteRoute.js"; +import InviteController from "./controllers/inviteController.js"; + +import MonitorRoutes from "./routes/monitorRoute.js"; +import MonitorController from "./controllers/monitorController.js"; + +import CheckRoutes from "./routes/checkRoute.js"; +import CheckController from "./controllers/checkController.js"; + +import MaintenanceWindowRoutes from "./routes/maintenanceWindowRoute.js"; +import MaintenanceWindowController from "./controllers/maintenanceWindowController.js"; + +import SettingsRoutes from "./routes/settingsRoute.js"; +import SettingsController from "./controllers/settingsController.js"; + +import StatusPageRoutes from "./routes/statusPageRoute.js"; +import StatusPageController from "./controllers/statusPageController.js"; + +import QueueRoutes from "./routes/queueRoute.js"; +import QueueController from "./controllers/queueController.js"; + +import DistributedUptimeRoutes from "./routes/distributedUptimeRoute.js"; +import DistributedUptimeController from "./controllers/distributedUptimeController.js"; + +import NotificationRoutes from "./routes/notificationRoute.js"; +import NotificationController from "./controllers/notificationController.js"; + +import DiagnosticRoutes from "./routes/diagnosticRoute.js"; +import DiagnosticController from "./controllers/diagnosticController.js"; + +//JobQueue service and dependencies +import JobQueue from "./service/jobQueue.js"; +import { Queue, Worker } from "bullmq"; + +//Network service and dependencies +import NetworkService from "./service/networkService.js"; +import axios from "axios"; +import ping from "ping"; +import http from "http"; +import Docker from "dockerode"; +import net from "net"; +// Email service and dependencies +import EmailService from "./service/emailService.js"; +import nodemailer from "nodemailer"; +import pkg from "handlebars"; +const { compile } = pkg; +import mjml2html from "mjml"; + +// Settings Service and dependencies +import SettingsService from "./service/settingsService.js"; +import AppSettings from "./db/models/AppSettings.js"; + +// Status Service and dependencies +import StatusService from "./service/statusService.js"; + +// Notification Service and dependencies +import NotificationService from "./service/notificationService.js"; + +// Buffer Service and dependencies +import BufferService from "./service/bufferService.js"; + +// Service Registry +import ServiceRegistry from "./service/serviceRegistry.js"; + +import MongoDB from "./db/mongo/MongoDB.js"; + +import IORedis from "ioredis"; + +import TranslationService from "./service/translationService.js"; +import languageMiddleware from "./middleware/languageMiddleware.js"; +import StringService from "./service/stringService.js"; + +const SERVICE_NAME = "Server"; +const SHUTDOWN_TIMEOUT = 1000; +let isShuttingDown = false; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const openApiSpec = JSON.parse( + fs.readFileSync(path.join(__dirname, "openapi.json"), "utf8") +); + +let server; + +const PORT = 5000; + +const shutdown = async () => { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + logger.info({ message: "Attempting graceful shutdown" }); + setTimeout(async () => { + logger.error({ + message: "Could not shut down in time, forcing shutdown", + service: SERVICE_NAME, + method: "shutdown", + }); + // flush Redis + const settings = + ServiceRegistry.get(SettingsService.SERVICE_NAME).getSettings() || {}; + + const { redisUrl } = settings; + const redis = new IORedis(redisUrl, { maxRetriesPerRequest: null }); + + logger.info({ message: "Flushing Redis" }); + await redis.flushall(); + logger.info({ message: "Redis flushed" }); + process.exit(1); + }, SHUTDOWN_TIMEOUT); + try { + server.close(); + await ServiceRegistry.get(JobQueue.SERVICE_NAME).obliterate(); + await ServiceRegistry.get(MongoDB.SERVICE_NAME).disconnect(); + logger.info({ message: "Graceful shutdown complete" }); + process.exit(0); + } catch (error) { + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "shutdown", + stack: error.stack, + }); + } +}; +// Need to wrap server setup in a function to handle async nature of JobQueue +const startApp = async () => { + const app = express(); + const allowedOrigin = process.env.CLIENT_HOST; + // Create and Register Primary services + const translationService = new TranslationService(logger); + const stringService = new StringService(translationService); + ServiceRegistry.register(StringService.SERVICE_NAME, stringService); + + // Create DB + const db = new MongoDB(); + await db.connect(); + + // Create services + const settingsService = new SettingsService(AppSettings); + await settingsService.loadSettings(); + + const networkService = new NetworkService( + axios, + ping, + logger, + http, + Docker, + net, + stringService, + settingsService + ); + const emailService = new EmailService( + settingsService, + fs, + path, + compile, + mjml2html, + nodemailer, + logger + ); + const bufferService = new BufferService({ db, logger }); + const statusService = new StatusService({ db, logger, buffer: bufferService }); + const notificationService = new NotificationService( + emailService, + db, + logger, + networkService, + stringService + ); + + const jobQueue = new JobQueue( + db, + statusService, + networkService, + notificationService, + settingsService, + stringService, + logger, + Queue, + Worker + ); + + // Register services + ServiceRegistry.register(JobQueue.SERVICE_NAME, jobQueue); + ServiceRegistry.register(MongoDB.SERVICE_NAME, db); + ServiceRegistry.register(SettingsService.SERVICE_NAME, settingsService); + ServiceRegistry.register(EmailService.SERVICE_NAME, emailService); + ServiceRegistry.register(NetworkService.SERVICE_NAME, networkService); + ServiceRegistry.register(BufferService.SERVICE_NAME, bufferService); + ServiceRegistry.register(StatusService.SERVICE_NAME, statusService); + ServiceRegistry.register(NotificationService.SERVICE_NAME, notificationService); + ServiceRegistry.register(TranslationService.SERVICE_NAME, translationService); + + await translationService.initialize(); + + server = app.listen(PORT, () => { + logger.info({ message: `server started on port:${PORT}` }); + }); + + process.on("SIGUSR2", shutdown); + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + //Create controllers + const authController = new AuthController( + ServiceRegistry.get(MongoDB.SERVICE_NAME), + ServiceRegistry.get(SettingsService.SERVICE_NAME), + ServiceRegistry.get(EmailService.SERVICE_NAME), + ServiceRegistry.get(JobQueue.SERVICE_NAME), + ServiceRegistry.get(StringService.SERVICE_NAME) + ); + + const monitorController = new MonitorController( + ServiceRegistry.get(MongoDB.SERVICE_NAME), + ServiceRegistry.get(SettingsService.SERVICE_NAME), + ServiceRegistry.get(JobQueue.SERVICE_NAME), + ServiceRegistry.get(StringService.SERVICE_NAME) + ); + + const settingsController = new SettingsController( + ServiceRegistry.get(MongoDB.SERVICE_NAME), + ServiceRegistry.get(SettingsService.SERVICE_NAME), + ServiceRegistry.get(StringService.SERVICE_NAME) + ); + + const checkController = new CheckController( + ServiceRegistry.get(MongoDB.SERVICE_NAME), + ServiceRegistry.get(SettingsService.SERVICE_NAME), + ServiceRegistry.get(StringService.SERVICE_NAME) + ); + + const inviteController = new InviteController( + ServiceRegistry.get(MongoDB.SERVICE_NAME), + ServiceRegistry.get(SettingsService.SERVICE_NAME), + ServiceRegistry.get(EmailService.SERVICE_NAME), + ServiceRegistry.get(StringService.SERVICE_NAME) + ); + + const maintenanceWindowController = new MaintenanceWindowController( + ServiceRegistry.get(MongoDB.SERVICE_NAME), + ServiceRegistry.get(SettingsService.SERVICE_NAME), + ServiceRegistry.get(StringService.SERVICE_NAME) + ); + + const queueController = new QueueController( + ServiceRegistry.get(JobQueue.SERVICE_NAME), + ServiceRegistry.get(StringService.SERVICE_NAME) + ); + + const statusPageController = new StatusPageController( + ServiceRegistry.get(MongoDB.SERVICE_NAME), + ServiceRegistry.get(StringService.SERVICE_NAME) + ); + + const notificationController = new NotificationController( + ServiceRegistry.get(NotificationService.SERVICE_NAME), + ServiceRegistry.get(StringService.SERVICE_NAME), + ServiceRegistry.get(StatusService.SERVICE_NAME) + ); + + const distributedUptimeController = new DistributedUptimeController({ + db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + http, + statusService: ServiceRegistry.get(StatusService.SERVICE_NAME), + logger, + }); + + const diagnosticController = new DiagnosticController( + ServiceRegistry.get(MongoDB.SERVICE_NAME) + ); + + //Create routes + const authRoutes = new AuthRoutes(authController); + const monitorRoutes = new MonitorRoutes(monitorController); + const settingsRoutes = new SettingsRoutes(settingsController); + const checkRoutes = new CheckRoutes(checkController); + const inviteRoutes = new InviteRoutes(inviteController); + const maintenanceWindowRoutes = new MaintenanceWindowRoutes( + maintenanceWindowController + ); + const queueRoutes = new QueueRoutes(queueController); + const statusPageRoutes = new StatusPageRoutes(statusPageController); + const distributedUptimeRoutes = new DistributedUptimeRoutes( + distributedUptimeController + ); + const notificationRoutes = new NotificationRoutes(notificationController); + const diagnosticRoutes = new DiagnosticRoutes(diagnosticController); + // Init job queue + await jobQueue.initJobQueue(); + // Middleware + app.use(responseHandler); + app.use( + cors({ + origin: allowedOrigin, + methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS", + allowedHeaders: "*", + credentials: true, + }) + ); + app.use(express.json()); + app.use(helmet()); + app.use( + compression({ + level: 6, + threshold: 1024, + filter: (req, res) => { + if (req.headers["x-no-compression"]) { + return false; + } + return compression.filter(req, res); + }, + }) + ); + + app.use(languageMiddleware(stringService, translationService, settingsService)); + // Swagger UI + app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(openApiSpec)); + + //routes + app.use("/api/v1/auth", authRoutes.getRouter()); + app.use("/api/v1/settings", verifyJWT, settingsRoutes.getRouter()); + app.use("/api/v1/invite", inviteRoutes.getRouter()); + app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter()); + app.use("/api/v1/checks", verifyJWT, checkRoutes.getRouter()); + app.use("/api/v1/maintenance-window", verifyJWT, maintenanceWindowRoutes.getRouter()); + app.use("/api/v1/queue", verifyJWT, queueRoutes.getRouter()); + app.use("/api/v1/distributed-uptime", distributedUptimeRoutes.getRouter()); + app.use("/api/v1/status-page", statusPageRoutes.getRouter()); + app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); + app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); + app.use("/api/v1/health", (req, res) => { + res.json({ + status: "OK", + }); + }); + app.use(handleErrors); +}; + +startApp().catch((error) => { + logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "startApp", + stack: error.stack, + }); + process.exit(1); +}); diff --git a/server/locales/en.json b/server/locales/en.json new file mode 100755 index 000000000..449ab5a8e --- /dev/null +++ b/server/locales/en.json @@ -0,0 +1,162 @@ +{ + "dontHaveAccount": "Don't have account", + "email": "E-mail", + "forgotPassword": "Forgot Password", + "password": "password", + "signUp": "Sign up", + "submit": "Submit", + "title": "Title", + "continue": "Continue", + "enterEmail": "Enter your email", + "authLoginTitle": "Log In", + "authLoginEnterPassword": "Enter your password", + "commonPassword": "Password", + "commonBack": "Back", + "authForgotPasswordTitle": "Forgot password?", + "authForgotPasswordResetPassword": "Reset password", + "createPassword": "Create your password", + "createAPassword": "Create a password", + "authRegisterAlreadyHaveAccount": "Already have an account?", + "commonAppName": "BlueWave Uptime", + "authLoginEnterEmail": "Enter your email", + "authRegisterTitle": "Create an account", + "authRegisterStepOneTitle": "Create your account", + "authRegisterStepOneDescription": "Enter your details to get started", + "authRegisterStepTwoTitle": "Set up your profile", + "authRegisterStepTwoDescription": "Tell us more about yourself", + "authRegisterStepThreeTitle": "Almost done!", + "authRegisterStepThreeDescription": "Review your information", + "authForgotPasswordDescription": "No worries, we'll send you reset instructions.", + "authForgotPasswordSendInstructions": "Send instructions", + "authForgotPasswordBackTo": "Back to", + "authCheckEmailTitle": "Check your email", + "authCheckEmailDescription": "We sent a password reset link to {{email}}", + "authCheckEmailResendEmail": "Resend email", + "authCheckEmailBackTo": "Back to", + "goBackTo": "Go back to", + "authCheckEmailDidntReceiveEmail": "Didn't receive the email?", + "authCheckEmailClickToResend": "Click to resend", + "authSetNewPasswordTitle": "Set new password", + "authSetNewPasswordDescription": "Your new password must be different from previously used passwords.", + "authSetNewPasswordNewPassword": "New password", + "authSetNewPasswordConfirmPassword": "Confirm password", + "confirmPassword": "Confirm your password", + "authSetNewPasswordResetPassword": "Reset password", + "authSetNewPasswordBackTo": "Back to", + "authPasswordMustBeAtLeast": "Must be at least", + "authPasswordCharactersLong": "8 characters long", + "authPasswordMustContainAtLeast": "Must contain at least", + "authPasswordSpecialCharacter": "one special character", + "authPasswordOneNumber": "one number", + "authPasswordUpperCharacter": "one upper character", + "authPasswordLowerCharacter": "one lower character", + "authPasswordConfirmAndPassword": "Confirm password and password", + "authPasswordMustMatch": "must match", + "friendlyError": "Something went wrong...", + "unknownError": "An unknown error occurred", + "unauthorized": "Unauthorized access", + "authAdminExists": "Admin already exists", + "authInviteNotFound": "Invite not found", + "unknownService": "Unknown service", + "noAuthToken": "No auth token provided", + "invalidAuthToken": "Invalid auth token", + "expiredAuthToken": "Token expired", + "noRefreshToken": "No refresh token provided", + "invalidRefreshToken": "Invalid refresh token", + "expiredRefreshToken": "Refresh token expired", + "requestNewAccessToken": "Request new access token", + "invalidPayload": "Invalid payload", + "verifyOwnerNotFound": "Document not found", + "verifyOwnerUnauthorized": "Unauthorized access", + "insufficientPermissions": "Insufficient permissions", + "dbUserExists": "User already exists", + "dbUserNotFound": "User not found", + "dbTokenNotFound": "Token not found", + "dbResetPasswordBadMatch": "New password must be different from old password", + "dbFindMonitorById": "Monitor with id ${monitorId} not found", + "dbDeleteChecks": "No checks found for monitor with id ${monitorId}", + "authIncorrectPassword": "Incorrect password", + "authUnauthorized": "Unauthorized access", + "monitorGetById": "Monitor not found", + "monitorGetByUserId": "No monitors found for user", + "jobQueueWorkerClose": "Error closing worker", + "jobQueueDeleteJob": "Job not found in queue", + "jobQueueObliterate": "Error obliterating queue", + "pingCannotResolve": "No response", + "statusPageNotFound": "Status page not found", + "statusPageUrlNotUnique": "Status page url must be unique", + "dockerFail": "Failed to fetch Docker container information", + "dockerNotFound": "Docker container not found", + "portFail": "Failed to connect to port", + "alertCreate": "Alert created successfully", + "alertGetByUser": "Got alerts successfully", + "alertGetByMonitor": "Got alerts by Monitor successfully", + "alertGetById": "Got alert by Id successfully", + "alertEdit": "Alert edited successfully", + "alertDelete": "Alert deleted successfully", + "authCreateUser": "User created successfully", + "authLoginUser": "User logged in successfully", + "authLogoutUser": "User logged out successfully", + "authUpdateUser": "User updated successfully", + "authCreateRecoveryToken": "Recovery token created successfully", + "authVerifyRecoveryToken": "Recovery token verified successfully", + "authResetPassword": "Password reset successfully", + "authAdminCheck": "Admin check completed successfully", + "authDeleteUser": "User deleted successfully", + "authTokenRefreshed": "Auth token is refreshed", + "authGetAllUsers": "Got all users successfully", + "inviteIssued": "Invite sent successfully", + "inviteVerified": "Invite verified successfully", + "checkCreate": "Check created successfully", + "checkGet": "Got checks successfully", + "checkDelete": "Checks deleted successfully", + "checkUpdateTtl": "Checks TTL updated successfully", + "monitorGetAll": "Got all monitors successfully", + "monitorStatsById": "Got monitor stats by Id successfully", + "monitorGetByIdSuccess": "Got monitor by Id successfully", + "monitorGetByTeamId": "Got monitors by Team Id successfully", + "monitorGetByUserIdSuccess": "Got monitor for ${userId} successfully", + "monitorCreate": "Monitor created successfully", + "bulkMonitorsCreate": "Monitors created successfully", + "monitorDelete": "Monitor deleted successfully", + "monitorEdit": "Monitor edited successfully", + "monitorCertificate": "Got monitor certificate successfully", + "monitorDemoAdded": "Successfully added demo monitors", + "queueGetMetrics": "Got metrics successfully", + "queueAddJob": "Job added successfully", + "queueObliterate": "Queue obliterated", + "jobQueueDeleteJobSuccess": "Job removed successfully", + "jobQueuePauseJob": "Job paused successfully", + "jobQueueResumeJob": "Job resumed successfully", + "maintenanceWindowGetById": "Got Maintenance Window by Id successfully", + "maintenanceWindowCreate": "Maintenance Window created successfully", + "maintenanceWindowGetByTeam": "Got Maintenance Windows by Team successfully", + "maintenanceWindowDelete": "Maintenance Window deleted successfully", + "maintenanceWindowEdit": "Maintenance Window edited successfully", + "pingSuccess": "Success", + "getAppSettings": "Got app settings successfully", + "updateAppSettings": "Updated app settings successfully", + "statusPageByUrl": "Got status page by url successfully", + "statusPageCreate": "Status page created successfully", + "newTermsAdded": "New terms added to POEditor", + "dockerSuccess": "Docker container status fetched successfully", + "portSuccess": "Port connected successfully", + "monitorPause": "Monitor paused successfully", + "monitorResume": "Monitor resumed successfully", + "statusPageDelete": "Status page deleted successfully", + "statusPageUpdate": "Status page updated successfully", + "statusPageByTeamId": "Got status pages by team id successfully", + "httpNetworkError": "Network error", + "httpNotJson": "Response data is not json", + "httpJsonPathError": "Failed to parse json data", + "httpEmptyResult": "Result is empty", + "httpMatchSuccess": "Response data match successfully", + "httpMatchFail": "Failed to match response data", + "webhookSendSuccess": "Webhook notification sent successfully", + "telegramRequiresBotTokenAndChatId": "Telegram notifications require both botToken and chatId", + "webhookUrlRequired": "Webhook URL is required", + "platformRequired": "Platform is required", + "testNotificationFailed": "Failed to send test notification", + "monitorUpAlert": "Uptime Alert: One of your monitors is back online.\n📌 Monitor: {monitorName}\n📅 Time: {time}\n⚠️ Status: UP\n📟 Status Code: {code}\n\u200B\n", + "monitorDownAlert": "Downtime Alert: One of your monitors went offline.\n📌 Monitor: {monitorName}\n📅 Time: {time}\n⚠️ Status: DOWN\n📟 Status Code: {code}\n\u200B\n" +} diff --git a/server/locales/en.json.bak b/server/locales/en.json.bak new file mode 100755 index 000000000..eaef58eef --- /dev/null +++ b/server/locales/en.json.bak @@ -0,0 +1,147 @@ +{ + "dontHaveAccount": "Don't have account", + "email": "E-mail", + "forgotPassword": "Forgot Password", + "password": "password", + "signUp": "Sign up", + "submit": "Submit", + "title": "Title", + "continue": "Continue", + "enterEmail": "Enter your email", + "authLoginTitle": "Log In", + "authLoginEnterPassword": "Enter your password", + "commonPassword": "Password", + "commonBack": "Back", + "authForgotPasswordTitle": "Forgot password?", + "authForgotPasswordResetPassword": "Reset password", + "createPassword": "Create your password", + "createAPassword": "Create a password", + "authRegisterAlreadyHaveAccount": "Already have an account?", + "commonAppName": "BlueWave Uptime", + "authLoginEnterEmail": "Enter your email", + "authRegisterTitle": "Create an account", + "authRegisterStepOneTitle": "Create your account", + "authRegisterStepOneDescription": "Enter your details to get started", + "authRegisterStepTwoTitle": "Set up your profile", + "authRegisterStepTwoDescription": "Tell us more about yourself", + "authRegisterStepThreeTitle": "Almost done!", + "authRegisterStepThreeDescription": "Review your information", + "authForgotPasswordDescription": "No worries, we'll send you reset instructions.", + "authForgotPasswordSendInstructions": "Send instructions", + "authForgotPasswordBackTo": "Back to", + "authCheckEmailTitle": "Check your email", + "authCheckEmailDescription": "We sent a password reset link to {{email}}", + "authCheckEmailResendEmail": "Resend email", + "authCheckEmailBackTo": "Back to", + "goBackTo": "Go back to", + "authCheckEmailDidntReceiveEmail": "Didn't receive the email?", + "authCheckEmailClickToResend": "Click to resend", + "authSetNewPasswordTitle": "Set new password", + "authSetNewPasswordDescription": "Your new password must be different from previously used passwords.", + "authSetNewPasswordNewPassword": "New password", + "authSetNewPasswordConfirmPassword": "Confirm password", + "confirmPassword": "Confirm your password", + "authSetNewPasswordResetPassword": "Reset password", + "authSetNewPasswordBackTo": "Back to", + "authPasswordMustBeAtLeast": "Must be at least", + "authPasswordCharactersLong": "8 characters long", + "authPasswordMustContainAtLeast": "Must contain at least", + "authPasswordSpecialCharacter": "one special character", + "authPasswordOneNumber": "one number", + "authPasswordUpperCharacter": "one upper character", + "authPasswordLowerCharacter": "one lower character", + "authPasswordConfirmAndPassword": "Confirm password and password", + "authPasswordMustMatch": "must match", + "friendlyError": "Something went wrong...", + "unknownError": "An unknown error occurred", + "unauthorized": "Unauthorized access", + "authAdminExists": "Admin already exists", + "authInviteNotFound": "Invite not found", + "unknownService": "Unknown service", + "noAuthToken": "No auth token provided", + "invalidAuthToken": "Invalid auth token", + "expiredAuthToken": "Token expired", + "noRefreshToken": "No refresh token provided", + "invalidRefreshToken": "Invalid refresh token", + "expiredRefreshToken": "Refresh token expired", + "requestNewAccessToken": "Request new access token", + "invalidPayload": "Invalid payload", + "verifyOwnerNotFound": "Document not found", + "verifyOwnerUnauthorized": "Unauthorized access", + "insufficientPermissions": "Insufficient permissions", + "dbUserExists": "User already exists", + "dbUserNotFound": "User not found", + "dbTokenNotFound": "Token not found", + "dbResetPasswordBadMatch": "New password must be different from old password", + "dbFindMonitorById": "Monitor with id ${monitorId} not found", + "dbDeleteChecks": "No checks found for monitor with id ${monitorId}", + "authIncorrectPassword": "Incorrect password", + "authUnauthorized": "Unauthorized access", + "monitorGetById": "Monitor not found", + "monitorGetByUserId": "No monitors found for user", + "jobQueueWorkerClose": "Error closing worker", + "jobQueueDeleteJob": "Job not found in queue", + "jobQueueObliterate": "Error obliterating queue", + "pingCannotResolve": "No response", + "statusPageNotFound": "Status page not found", + "statusPageUrlNotUnique": "Status page url must be unique", + "dockerFail": "Failed to fetch Docker container information", + "dockerNotFound": "Docker container not found", + "portFail": "Failed to connect to port", + "alertCreate": "Alert created successfully", + "alertGetByUser": "Got alerts successfully", + "alertGetByMonitor": "Got alerts by Monitor successfully", + "alertGetById": "Got alert by Id successfully", + "alertEdit": "Alert edited successfully", + "alertDelete": "Alert deleted successfully", + "authCreateUser": "User created successfully", + "authLoginUser": "User logged in successfully", + "authLogoutUser": "User logged out successfully", + "authUpdateUser": "User updated successfully", + "authCreateRecoveryToken": "Recovery token created successfully", + "authVerifyRecoveryToken": "Recovery token verified successfully", + "authResetPassword": "Password reset successfully", + "authAdminCheck": "Admin check completed successfully", + "authDeleteUser": "User deleted successfully", + "authTokenRefreshed": "Auth token is refreshed", + "authGetAllUsers": "Got all users successfully", + "inviteIssued": "Invite sent successfully", + "inviteVerified": "Invite verified successfully", + "checkCreate": "Check created successfully", + "checkGet": "Got checks successfully", + "checkDelete": "Checks deleted successfully", + "checkUpdateTtl": "Checks TTL updated successfully", + "monitorGetAll": "Got all monitors successfully", + "monitorStatsById": "Got monitor stats by Id successfully", + "monitorGetByIdSuccess": "Got monitor by Id successfully", + "monitorGetByTeamId": "Got monitors by Team Id successfully", + "monitorGetByUserIdSuccess": "Got monitor for ${userId} successfully", + "monitorCreate": "Monitor created successfully", + "monitorDelete": "Monitor deleted successfully", + "monitorEdit": "Monitor edited successfully", + "monitorCertificate": "Got monitor certificate successfully", + "monitorDemoAdded": "Successfully added demo monitors", + "queueGetMetrics": "Got metrics successfully", + "queueAddJob": "Job added successfully", + "queueObliterate": "Queue obliterated", + "jobQueueDeleteJobSuccess": "Job removed successfully", + "jobQueuePauseJob": "Job paused successfully", + "jobQueueResumeJob": "Job resumed successfully", + "maintenanceWindowGetById": "Got Maintenance Window by Id successfully", + "maintenanceWindowCreate": "Maintenance Window created successfully", + "maintenanceWindowGetByTeam": "Got Maintenance Windows by Team successfully", + "maintenanceWindowDelete": "Maintenance Window deleted successfully", + "maintenanceWindowEdit": "Maintenance Window edited successfully", + "pingSuccess": "Success", + "getAppSettings": "Got app settings successfully", + "updateAppSettings": "Updated app settings successfully", + "statusPageByUrl": "Got status page by url successfully", + "statusPageCreate": "Status page created successfully", + "newTermsAdded": "New terms added to POEditor", + "dockerSuccess": "Docker container status fetched successfully", + "portSuccess": "Port connected successfully", + "monitorPause": "Monitor paused successfully", + "monitorResume": "Monitor resumed successfully", + "statusPageDelete": "Status page deleted successfully", + "statusPageUpdate": "Status page updated successfully" +} diff --git a/server/locales/tr.json b/server/locales/tr.json new file mode 100755 index 000000000..cc1ef4c64 --- /dev/null +++ b/server/locales/tr.json @@ -0,0 +1,146 @@ +{ + "dontHaveAccount": "Hesabınız yok mu", + "email": "E-posta", + "forgotPassword": "Parolamı Unuttum", + "password": "Parola", + "signUp": "Kayıt ol", + "submit": "Gönder", + "title": "Başlık", + "continue": "Devam et", + "enterEmail": "E-posta adresinizi girin", + "authLoginTitle": "Giriş Yap", + "authLoginEnterPassword": "Parolanızı girin", + "commonPassword": "Parola", + "commonBack": "Geri", + "authForgotPasswordTitle": "Parolanı mi unuttun?", + "authForgotPasswordResetPassword": "Parola sıfırla", + "createPassword": "Parolanızı oluşturun", + "createAPassword": "Bir parola oluşturun", + "authRegisterAlreadyHaveAccount": "Zaten hesabınız var mı?", + "commonAppName": "BlueWave Uptime", + "authLoginEnterEmail": "E-posta adresinizi girin", + "authRegisterTitle": "Hesap oluştur", + "authRegisterStepOneTitle": "Hesabınızı oluşturun", + "authRegisterStepOneDescription": "Başlamak için bilgilerinizi girin", + "authRegisterStepTwoTitle": "Profilinizi ayarlayın", + "authRegisterStepTwoDescription": "Kendiniz hakkında daha fazla bilgi verin", + "authRegisterStepThreeTitle": "Neredeyse bitti!", + "authRegisterStepThreeDescription": "Bilgilerinizi gözden geçirin", + "authForgotPasswordDescription": "Endişelenmeyin, size sıfırlama talimatlarını göndereceğiz.", + "authForgotPasswordSendInstructions": "Talimatları gönder", + "authForgotPasswordBackTo": "Geri dön", + "authCheckEmailTitle": "E-postanızı kontrol edin", + "authCheckEmailDescription": "{{email}} adresine bir şifre sıfırlama bağlantısı gönderdik", + "authCheckEmailResendEmail": "E-postayı yeniden gönder", + "authCheckEmailBackTo": "Geri dön", + "goBackTo": "Geri dön", + "authCheckEmailDidntReceiveEmail": "E-postayı almadınız mı?", + "authCheckEmailClickToResend": "Yeniden göndermek için tıklayın", + "authSetNewPasswordTitle": "Yeni şifre belirleyin", + "authSetNewPasswordDescription": "Yeni şifreniz, daha önce kullanılan şifrelerden farklı olmalıdır.", + "authSetNewPasswordNewPassword": "Yeni şifre", + "authSetNewPasswordConfirmPassword": "Parolayı onayla", + "confirmPassword": "Parolanızı onaylayın", + "authSetNewPasswordResetPassword": "Parolayı sıfırla", + "authSetNewPasswordBackTo": "Geri dön", + "authPasswordMustBeAtLeast": "En az", + "authPasswordCharactersLong": "8 karakter uzunluğunda olmalı", + "authPasswordMustContainAtLeast": "En az içermeli", + "authPasswordSpecialCharacter": "bir özel karakter", + "authPasswordOneNumber": "bir rakam", + "authPasswordUpperCharacter": "bir büyük harf", + "authPasswordLowerCharacter": "bir küçük harf", + "authPasswordConfirmAndPassword": "Onay şifresi ve şifre", + "authPasswordMustMatch": "eşleşmelidir", + "friendlyError": "Bir şeyler yanlış gitti...", + "unknownError": "Bilinmeyen bir hata oluştu", + "unauthorized": "Yetkisiz erişim", + "authAdminExists": "Yönetici zaten mevcut", + "authInviteNotFound": "Davet bulunamadı", + "unknownService": "Bilinmeyen servis", + "noAuthToken": "Kimlik doğrulama belirteci sağlanmadı", + "invalidAuthToken": "Geçersiz kimlik doğrulama belirteci", + "expiredAuthToken": "Belirteç süresi doldu", + "noRefreshToken": "Yenileme belirteci sağlanmadı", + "invalidRefreshToken": "Geçersiz yenileme belirteci", + "expiredRefreshToken": "Yenileme belirteci süresi doldu", + "requestNewAccessToken": "Yeni erişim belirteci isteyin", + "invalidPayload": "Geçersiz veri", + "verifyOwnerNotFound": "Belge bulunamadı", + "verifyOwnerUnauthorized": "Yetkisiz erişim", + "insufficientPermissions": "Yetersiz izinler", + "dbUserExists": "Kullanıcı zaten mevcut", + "dbUserNotFound": "Kullanıcı bulunamadı", + "dbTokenNotFound": "Belirteç bulunamadı", + "dbResetPasswordBadMatch": "Yeni şifre eski şifreden farklı olmalıdır", + "dbFindMonitorById": "${monitorId} kimlikli monitör bulunamadı", + "dbDeleteChecks": "${monitorId} kimlikli monitör için kontrol bulunamadı", + "authIncorrectPassword": "Geçersiz parola", + "authUnauthorized": "Yetkisiz erişim", + "monitorGetById": "Monitör bulunamadı", + "monitorGetByUserId": "Kullanıcı için monitör bulunamadı", + "jobQueueWorkerClose": "İşçi kapatılırken hata oluştu", + "jobQueueDeleteJob": "İş kuyrukta bulunamadı", + "jobQueueObliterate": "Kuyruk yok edilirken hata oluştu", + "pingCannotResolve": "Yanıt yok", + "statusPageNotFound": "Durum sayfası bulunamadı", + "statusPageUrlNotUnique": "Durum sayfası URL'si benzersiz olmalıdır", + "dockerFail": "Docker konteyner bilgisi alınamadı", + "dockerNotFound": "Docker konteyner bulunamadı", + "portFail": "Porta bağlanılamadı", + "alertCreate": "Uyarı başarıyla oluşturuldu", + "alertGetByUser": "Uyarılar başarıyla alındı", + "alertGetByMonitor": "Monitöre göre uyarılar başarıyla alındı", + "alertGetById": "Kimliğe göre uyarı başarıyla alındı", + "alertEdit": "Uyarı başarıyla düzenlendi", + "alertDelete": "Uyarı başarıyla silindi", + "authCreateUser": "Kullanıcı başarıyla oluşturuldu", + "authLoginUser": "Kullanıcı başarıyla giriş yaptı", + "authLogoutUser": "Kullanıcı başarıyla çıkış yaptı", + "authUpdateUser": "Kullanıcı başarıyla güncellendi", + "authCreateRecoveryToken": "Kurtarma belirteci başarıyla oluşturuldu", + "authVerifyRecoveryToken": "Kurtarma belirteci başarıyla doğrulandı", + "authResetPassword": "Şifre başarıyla sıfırlandı", + "authAdminCheck": "Yönetici kontrolü başarıyla tamamlandı", + "authDeleteUser": "Kullanıcı başarıyla silindi", + "authTokenRefreshed": "Kimlik doğrulama belirteci yenilendi", + "authGetAllUsers": "Tüm kullanıcılar başarıyla alındı", + "inviteIssued": "Davet başarıyla gönderildi", + "inviteVerified": "Davet başarıyla doğrulandı", + "checkCreate": "Kontrol başarıyla oluşturuldu", + "checkGet": "Kontroller başarıyla alındı", + "checkDelete": "Kontroller başarıyla silindi", + "checkUpdateTtl": "Kontrol TTL başarıyla güncellendi", + "monitorGetAll": "Tüm monitörler başarıyla alındı", + "monitorStatsById": "Kimliğe göre monitör istatistikleri başarıyla alındı", + "monitorGetByIdSuccess": "Kimliğe göre monitör başarıyla alındı", + "monitorGetByTeamId": "Takım kimliğine göre monitörler başarıyla alındı", + "monitorGetByUserIdSuccess": "${userId} için monitör başarıyla alındı", + "monitorCreate": "Monitör başarıyla oluşturuldu", + "monitorDelete": "Monitör başarıyla silindi", + "monitorEdit": "Monitör başarıyla düzenlendi", + "monitorCertificate": "Monitör sertifikası başarıyla alındı", + "monitorDemoAdded": "Demo monitörler başarıyla eklendi", + "queueGetMetrics": "Metrikler başarıyla alındı", + "queueAddJob": "İş başarıyla eklendi", + "queueObliterate": "Kuyruk yok edildi", + "jobQueueDeleteJobSuccess": "İş başarıyla kaldırıldı", + "jobQueuePauseJob": "İş başarıyla duraklatıldı", + "jobQueueResumeJob": "İş başarıyla devam ettirildi", + "maintenanceWindowGetById": "Kimliğe göre bakım penceresi başarıyla alındı", + "maintenanceWindowCreate": "Bakım penceresi başarıyla oluşturuldu", + "maintenanceWindowGetByTeam": "Takıma göre bakım pencereleri başarıyla alındı", + "maintenanceWindowDelete": "Bakım penceresi başarıyla silindi", + "maintenanceWindowEdit": "Bakım penceresi başarıyla düzenlendi", + "pingSuccess": "Başarılı", + "getAppSettings": "Uygulama ayarları başarıyla alındı", + "updateAppSettings": "Uygulama ayarları başarıyla güncellendi", + "statusPageByUrl": "URL'ye göre durum sayfası başarıyla alındı", + "statusPageCreate": "Durum sayfası başarıyla oluşturuldu", + "dockerSuccess": "Docker konteyner durumu başarıyla alındı", + "portSuccess": "Porta başarıyla bağlanıldı", + "newTermsAdded": "POEditor'a yeni terimler eklendi", + "monitorPause": "Monitör başarıyla duraklatıldı", + "monitorResume": "Monitör başarıyla devam ettirildi", + "monitorDelete2": "" +} diff --git a/server/middleware/handleErrors.js b/server/middleware/handleErrors.js new file mode 100755 index 000000000..b64cda897 --- /dev/null +++ b/server/middleware/handleErrors.js @@ -0,0 +1,22 @@ +import logger from "../utils/logger.js"; +import ServiceRegistry from "../service/serviceRegistry.js"; +import StringService from "../service/stringService.js"; + +const handleErrors = (error, req, res, next) => { + const status = error.status || 500; + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + const message = error.message || stringService.friendlyError; + const service = error.service || stringService.unknownService; + logger.error({ + message: message, + service: service, + method: error.method, + stack: error.stack, + }); + res.error({ + status, + msg: message, + }); +}; + +export { handleErrors }; diff --git a/server/middleware/isAllowed.js b/server/middleware/isAllowed.js new file mode 100755 index 000000000..39289dfe8 --- /dev/null +++ b/server/middleware/isAllowed.js @@ -0,0 +1,59 @@ +import jwt from "jsonwebtoken"; +const TOKEN_PREFIX = "Bearer "; +const SERVICE_NAME = "allowedRoles"; +import ServiceRegistry from "../service/serviceRegistry.js"; +import StringService from "../service/stringService.js"; +import SettingsService from "../service/settingsService.js"; + +const isAllowed = (allowedRoles) => { + return (req, res, next) => { + const token = req.headers["authorization"]; + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + // If no token is pressent, return an error + if (!token) { + const error = new Error(stringService.noAuthToken); + error.status = 401; + error.service = SERVICE_NAME; + next(error); + return; + } + + // If the token is improperly formatted, return an error + if (!token.startsWith(TOKEN_PREFIX)) { + const error = new Error(stringService.invalidAuthToken); + error.status = 400; + error.service = SERVICE_NAME; + next(error); + return; + } + // Parse the token + try { + const parsedToken = token.slice(TOKEN_PREFIX.length, token.length); + const { jwtSecret } = ServiceRegistry.get( + SettingsService.SERVICE_NAME + ).getSettings(); + var decoded = jwt.verify(parsedToken, jwtSecret); + const userRoles = decoded.role; + + // Check if the user has the required role + if (userRoles.some((role) => allowedRoles.includes(role))) { + next(); + return; + } else { + const error = new Error(stringService.insufficientPermissions); + error.status = 401; + error.service = SERVICE_NAME; + next(error); + return; + } + } catch (error) { + error.status = 401; + error.method = "isAllowed"; + error.service = SERVICE_NAME; + next(error); + return; + } + }; +}; + +export { isAllowed }; diff --git a/server/middleware/languageMiddleware.js b/server/middleware/languageMiddleware.js new file mode 100755 index 000000000..fd32ee818 --- /dev/null +++ b/server/middleware/languageMiddleware.js @@ -0,0 +1,34 @@ +import logger from "../utils/logger.js"; + +const languageMiddleware = + (stringService, translationService, settingsService) => async (req, res, next) => { + try { + const settings = await settingsService.getSettings(); + + let language = settings && settings.language ? settings.language : null; + + if (!language) { + const acceptLanguage = req.headers["accept-language"] || "en"; + language = acceptLanguage.split(",")[0].slice(0, 2).toLowerCase(); + } + + translationService.setLanguage(language); + stringService.setLanguage(language); + + next(); + } catch (error) { + logger.error({ + message: error.message, + service: "languageMiddleware", + }); + const acceptLanguage = req.headers["accept-language"] || "en"; + const language = acceptLanguage.split(",")[0].slice(0, 2).toLowerCase(); + + translationService.setLanguage(language); + stringService.setLanguage(language); + + next(); + } + }; + +export default languageMiddleware; diff --git a/server/middleware/responseHandler.js b/server/middleware/responseHandler.js new file mode 100755 index 000000000..a58e2e845 --- /dev/null +++ b/server/middleware/responseHandler.js @@ -0,0 +1,46 @@ +/** + * Middleware that adds standardized response methods to the Express response object. + * This allows for consistent API responses throughout the application. + * + * @param {import('express').Request} req - Express request object + * @param {import('express').Response} res - Express response object + * @param {import('express').NextFunction} next - Express next middleware function + */ +const responseHandler = (req, res, next) => { + /** + * Sends a standardized success response + * + * @param {Object} options - Success response options + * @param {number} [options.status=200] - HTTP status code + * @param {string} [options.msg="OK"] - Success message + * @param {*} [options.data=null] - Response data payload + * @returns {Object} Express response object + */ + res.success = ({ status = 200, msg = "OK", data = null }) => { + return res.status(status).json({ + success: true, + msg: msg, + data: data, + }); + }; + + /** + * Sends a standardized error response + * + * @param {Object} options - Error response options + * @param {number} [options.status=500] - HTTP status code + * @param {string} [options.msg="Internal server error"] - Error message + * @param {*} [options.data=null] - Additional error data (if any) + * @returns {Object} Express response object + */ + res.error = ({ status = 500, msg = "Internal server error", data = null }) => { + return res.status(status).json({ + success: false, + msg, + data, + }); + }; + next(); +}; + +export { responseHandler }; diff --git a/server/middleware/verifyJWT.js b/server/middleware/verifyJWT.js new file mode 100755 index 000000000..bd17edcfd --- /dev/null +++ b/server/middleware/verifyJWT.js @@ -0,0 +1,57 @@ +import jwt from "jsonwebtoken"; +import ServiceRegistry from "../service/serviceRegistry.js"; +import SettingsService from "../service/settingsService.js"; +import StringService from "../service/stringService.js"; +const SERVICE_NAME = "verifyJWT"; +const TOKEN_PREFIX = "Bearer "; + +/** + * Verifies the JWT token + * @function + * @param {express.Request} req + * @param {express.Response} res + * @param {express.NextFunction} next + * @returns {express.Response} + */ +const verifyJWT = (req, res, next) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + const token = req.headers["authorization"]; + // Make sure a token is provided + if (!token) { + const error = new Error(stringService.noAuthToken); + error.status = 401; + error.service = SERVICE_NAME; + next(error); + return; + } + // Make sure it is properly formatted + if (!token.startsWith(TOKEN_PREFIX)) { + const error = new Error(stringService.invalidAuthToken); // Instantiate a new Error object for improperly formatted token + error.status = 401; + error.service = SERVICE_NAME; + error.method = "verifyJWT"; + next(error); + return; + } + + const parsedToken = token.slice(TOKEN_PREFIX.length, token.length); + // Verify the token's authenticity + const { jwtSecret } = ServiceRegistry.get(SettingsService.SERVICE_NAME).getSettings(); + jwt.verify(parsedToken, jwtSecret, (err, decoded) => { + if (err) { + if (err) { + const errorMessage = + err.name === "TokenExpiredError" + ? stringService.expiredAuthToken + : stringService.invalidAuthToken; + return res.status(401).json({ success: false, msg: errorMessage }); + } + } else { + // Token is valid, carry on + req.user = decoded; + next(); + } + }); +}; + +export { verifyJWT }; diff --git a/server/middleware/verifyOwnership.js b/server/middleware/verifyOwnership.js new file mode 100755 index 000000000..ca2476f54 --- /dev/null +++ b/server/middleware/verifyOwnership.js @@ -0,0 +1,53 @@ +import logger from "../utils/logger.js"; +import ServiceRegistry from "../service/serviceRegistry.js"; +import StringService from "../service/stringService.js"; +const SERVICE_NAME = "verifyOwnership"; + +const verifyOwnership = (Model, paramName) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + return async (req, res, next) => { + const userId = req.user._id; + const documentId = req.params[paramName]; + try { + const doc = await Model.findById(documentId); + //If the document is not found, return a 404 error + if (!doc) { + logger.error({ + message: stringService.verifyOwnerNotFound, + service: SERVICE_NAME, + method: "verifyOwnership", + }); + const error = new Error(stringService.verifyOwnerNotFound); + error.status = 404; + throw error; + } + + // Special case for User model, as it will not have a `userId` field as other docs will + if (Model.modelName === "User") { + if (userId.toString() !== doc._id.toString()) { + const error = new Error(stringService.verifyOwnerUnauthorized); + error.status = 403; + throw error; + } + next(); + return; + } + + // If the userID does not match the document's userID, return a 403 error + if (userId.toString() !== doc.userId.toString()) { + const error = new Error(stringService.verifyOwnerUnauthorized); + error.status = 403; + throw error; + } + next(); + return; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "verifyOwnership"; + next(error); + return; + } + }; +}; + +export { verifyOwnership }; diff --git a/server/middleware/verifySuperAdmin.js b/server/middleware/verifySuperAdmin.js new file mode 100755 index 000000000..98c336fad --- /dev/null +++ b/server/middleware/verifySuperAdmin.js @@ -0,0 +1,68 @@ +const jwt = require("jsonwebtoken"); +const logger = require("../utils/logger"); +const SERVICE_NAME = "verifyAdmin"; +const TOKEN_PREFIX = "Bearer "; +import ServiceRegistry from "../service/serviceRegistry.js"; +import SettingsService from "../service/settingsService.js"; +import StringService from "../service/stringService.js"; +/** + * Verifies the JWT token + * @function + * @param {express.Request} req + * @param {express.Response} res + * @param {express.NextFunction} next + * @returns {express.Response} + */ +const verifySuperAdmin = (req, res, next) => { + const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); + const token = req.headers["authorization"]; + // Make sure a token is provided + if (!token) { + const error = new Error(stringService.noAuthToken); + error.status = 401; + error.service = SERVICE_NAME; + next(error); + return; + } + // Make sure it is properly formatted + if (!token.startsWith(TOKEN_PREFIX)) { + const error = new Error(stringService.invalidAuthToken); // Instantiate a new Error object for improperly formatted token + error.status = 400; + error.service = SERVICE_NAME; + error.method = "verifySuperAdmin"; + next(error); + return; + } + + const parsedToken = token.slice(TOKEN_PREFIX.length, token.length); + // verify admin role is present + const { jwtSecret } = ServiceRegistry.get(SettingsService.SERVICE_NAME).getSettings(); + + jwt.verify(parsedToken, jwtSecret, (err, decoded) => { + if (err) { + logger.error({ + message: err.message, + service: SERVICE_NAME, + method: "verifySuperAdmin", + stack: err.stack, + details: stringService.invalidAuthToken, + }); + return res + .status(401) + .json({ success: false, msg: stringService.invalidAuthToken }); + } + + if (decoded.role.includes("superadmin") === false) { + logger.error({ + message: stringService.invalidAuthToken, + service: SERVICE_NAME, + method: "verifySuperAdmin", + stack: err.stack, + }); + return res.status(401).json({ success: false, msg: stringService.unauthorized }); + } + next(); + }); +}; + +module.exports = { verifySuperAdmin }; diff --git a/server/nodemon.json b/server/nodemon.json new file mode 100755 index 000000000..928d1abc2 --- /dev/null +++ b/server/nodemon.json @@ -0,0 +1,5 @@ +{ + "ignore": ["locales/*", "*.log", "node_modules/*"], + "watch": ["*.js", "*.json"], + "ext": "js,json" +} diff --git a/server/openapi.json b/server/openapi.json new file mode 100755 index 000000000..7b7bb3cb3 --- /dev/null +++ b/server/openapi.json @@ -0,0 +1,2562 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Checkmate", + "summary": "Checkmate OpenAPI Specifications", + "description": "Checkmate is an open source monitoring tool used to track the operational status and performance of servers and websites. It regularly checks whether a server/website is accessible and performs optimally, providing real-time alerts and reports on the monitored services' availability, downtime, and response time.", + "contact": { + "name": "API Support", + "url": "mailto:support@bluewavelabs.ca", + "email": "support@bluewavelabs.ca" + }, + "license": { + "name": "AGPLv3", + "url": "https://github.com/bluewave-labs/checkmate/tree/HEAD/LICENSE" + }, + "version": "1.0" + }, + "servers": [ + { + "url": "http://localhost:{PORT}/{API_PATH}", + "description": "Local Development Server", + "variables": { + "PORT": { + "description": "API Port", + "enum": ["5000"], + "default": "5000" + }, + "API_PATH": { + "description": "API Base Path", + "enum": ["api/v1"], + "default": "api/v1" + } + } + }, + { + "url": "http://localhost/{API_PATH}", + "description": "Distribution Local Development Server", + "variables": { + "API_PATH": { + "description": "API Base Path", + "enum": ["api/v1"], + "default": "api/v1" + } + } + }, + { + "url": "https://checkmate-demo.bluewavelabs.ca/{API_PATH}", + "description": "Checkmate Demo Server", + "variables": { + "PORT": { + "description": "API Port", + "enum": ["5000"], + "default": "5000" + }, + "API_PATH": { + "description": "API Base Path", + "enum": ["api/v1"], + "default": "api/v1" + } + } + } + ], + "tags": [ + { + "name": "auth", + "description": "Authentication" + }, + { + "name": "invite", + "description": "Invite" + }, + { + "name": "monitors", + "description": "Monitors" + }, + { + "name": "checks", + "description": "Checks" + }, + { + "name": "maintenance-window", + "description": "Maintenance window" + }, + { + "name": "queue", + "description": "Queue" + }, + { + "name": "status-page", + "description": "Status Page" + } + ], + "paths": { + "/auth/register": { + "post": { + "tags": ["auth"], + "description": "Register a new user", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "firstName", + "lastName", + "email", + "password", + "role", + "teamId" + ], + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "format": "password" + }, + "profileImage": { + "type": "file", + "format": "file" + }, + "role": { + "type": "array", + "enum": [["user"], ["admin"], ["superadmin"], ["Demo"]], + "default": ["superadmin"] + }, + "teamId": { + "type": "string", + "format": "uuid" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/auth/login": { + "post": { + "tags": ["auth"], + "description": "Login with credentials", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email", "password"], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "password": { + "type": "string", + "format": "password" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/auth/refresh": { + "post": { + "tags": ["auth"], + "description": "Generates a new auth token if the refresh token is valid.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + }, + "required": false + }, + "parameters": [ + { + "name": "x-refresh-token", + "in": "header", + "description": "Refresh token required to generate a new auth token.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "authorization", + "in": "header", + "description": "Old access token, used to extract payload).", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "New access token generated.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "401": { + "description": "Unauthorized or invalid refresh token.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/auth/user/{userId}": { + "put": { + "tags": ["auth"], + "description": "Change user information", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": ["auth"], + "description": "Delete user", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/auth/users/superadmin": { + "get": { + "tags": ["auth"], + "description": "Checks to see if an admin account exists", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/auth/users": { + "get": { + "tags": ["auth"], + "description": "Get all users", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/auth/recovery/request": { + "post": { + "tags": ["auth"], + "description": "Request a recovery token", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { + "type": "string", + "format": "email" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/auth/recovery/validate": { + "post": { + "tags": ["auth"], + "description": "Validate recovery token", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["recoveryToken"], + "properties": { + "recoveryToken": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/auth/recovery/reset": { + "post": { + "tags": ["auth"], + "description": "Password reset", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["recoveryToken", "password"], + "properties": { + "recoveryToken": { + "type": "string" + }, + "password": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/invite": { + "post": { + "tags": ["invite"], + "description": "Request an invitation", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["email", "role"], + "properties": { + "email": { + "type": "string" + }, + "role": { + "type": "array" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/invite/verify": { + "post": { + "tags": ["invite"], + "description": "Request an invitation", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["token"], + "properties": { + "token": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/monitors": { + "get": { + "tags": ["monitors"], + "description": "Get all monitors", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": ["monitors"], + "description": "Create a new monitor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMonitorBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": ["monitors"], + "description": "Delete all monitors", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/monitors/uptime": { + "get": { + "tags": ["monitors"], + "description": "Get all monitors with uptime stats for 1, 7, 30, and 90 days", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/monitors/resolution/url": { + "get": { + "tags": ["monitors"], + "description": "Check DNS resolution for a given URL", + "parameters": [ + { + "name": "monitorURL", + "in": "query", + "required": true, + "schema": { + "type": "string", + "example": "https://example.com" + }, + "description": "The URL to check DNS resolution for" + } + ], + "responses": { + "200": { + "description": "URL resolved successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "400": { + "description": "DNS resolution failed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/monitors/{monitorId}": { + "get": { + "tags": ["monitors"], + "description": "Get monitor by id", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "put": { + "tags": ["monitors"], + "description": "Update monitor by id", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateMonitorBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": ["monitors"], + "description": "Delete monitor by id", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/monitors/stats/{monitorId}": { + "get": { + "tags": ["monitors"], + "description": "Get monitor stats", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/monitors/certificate/{monitorId}": { + "get": { + "tags": ["monitors"], + "description": "Get monitor certificate", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/monitors/team/summary/{teamId}": { + "get": { + "tags": ["monitors"], + "description": "Get monitors and summary by teamId", + "parameters": [ + { + "name": "teamId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "type", + "in": "query", + "required": false, + "schema": { + "type": "array", + "enum": ["http", "ping", "pagespeed"] + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/monitors/team/{teamId}": { + "get": { + "tags": ["monitors"], + "description": "Get monitors by teamId", + "parameters": [ + { + "name": "teamId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "description": "Status of monitor, true for up, false for down", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "checkOrder", + "description": "Order of checks", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "description": "Number of checks to return with monitor", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "type", + "description": "Type of monitor", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["http", "ping", "pagespeed"] + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "rowsPerPage", + "in": "query", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "filter", + "description": "Value to filter by", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "field", + "description": "Field to filter on", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "order", + "description": "Sort order of results", + "in": "query", + "required": false, + "schema": { + "type": "string", + "enum": ["http", "ping", "pagespeed"] + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/monitors/pause/{monitorId}": { + "post": { + "tags": ["monitors"], + "description": "Pause monitor", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/monitors/demo": { + "post": { + "tags": ["monitors"], + "description": "Create a demo monitor", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMonitorBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/checks/{monitorId}": { + "get": { + "tags": ["checks"], + "description": "Get all checks for a monitor", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": ["checks"], + "description": "Create a new check", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCheckBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": ["checks"], + "description": "Delete all checks for a monitor", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/checks/team/{teamId}": { + "get": { + "tags": ["checks"], + "description": "Get all checks for a team", + "parameters": [ + { + "name": "teamId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": ["checks"], + "description": "Delete all checks for a team", + "parameters": [ + { + "name": "teamId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/checks/team/ttl": { + "put": { + "tags": ["checks"], + "description": "Update check TTL", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCheckTTLBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/maintenance-window/monitor/{monitorId}": { + "get": { + "tags": ["maintenance-window"], + "description": "Get maintenance window for monitor", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": ["maintenance-window"], + "description": "Create maintenance window for monitor", + "parameters": [ + { + "name": "monitorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateMaintenanceWindowBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/maintenance-window/user/{userId}": { + "get": { + "tags": ["maintenance-window"], + "description": "Get maintenance window for user", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/queue/jobs": { + "get": { + "tags": ["queue"], + "description": "Get all jobs in queue", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": ["queue"], + "description": "Create a new job. Useful for testing scaling workers", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/queue/metrics": { + "get": { + "tags": ["queue"], + "description": "Get queue metrics", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/queue/obliterate": { + "post": { + "tags": ["queue"], + "description": "Obliterate job queue", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/status-page/{url}": { + "get": { + "tags": ["status-page"], + "description": "Get a status page by URL", + "parameters": [ + { + "name": "url", + "in": "path", + "required": true, + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SuccessResponse" } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + } + }, + "post": { + "tags": ["status-page"], + "description": "Create a status page", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateStatusPageBody" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "422": { + "description": "Unprocessable Content", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "400": { + "description": "Duplicate URL", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "ErrorResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "default": false + }, + "msg": { + "type": "string" + } + } + }, + "SuccessResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "default": true + }, + "msg": { + "type": "string" + }, + "data": { + "type": "object" + } + } + }, + "UserUpdateRequest": { + "type": "object", + "required": ["firstName", "lastName", "email", "password", "role", "teamId"], + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "password": { + "type": "string", + "format": "password" + }, + "newPassword": { + "type": "string", + "format": "password" + }, + "profileImage": { + "type": "file", + "format": "file" + }, + "role": { + "type": "array", + "enum": [["user"], ["admin"], ["superadmin"], ["Demo"]], + "default": ["superadmin"] + }, + "deleteProfileImage": { + "type": "boolean" + } + } + }, + "CreateMonitorBody": { + "type": "object", + "required": ["userId", "teamId", "name", "description", "type", "url"], + "properties": { + "_id": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "teamId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["http", "ping", "pagespeed"] + }, + "url": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "interval": { + "type": "integer" + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "UpdateMonitorBody": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "interval": { + "type": "integer" + }, + "notifications": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "CreateCheckBody": { + "type": "object", + "required": ["monitorId", "status", "responseTime", "statusCode", "message"], + "properties": { + "monitorId": { + "type": "string" + }, + "status": { + "type": "boolean" + }, + "responseTime": { + "type": "integer" + }, + "statusCode": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, + "UpdateCheckTTLBody": { + "type": "object", + "required": ["ttl"], + "properties": { + "ttl": { + "type": "integer" + } + } + }, + "CreateMaintenanceWindowBody": { + "type": "object", + "required": ["userId", "active", "oneTime", "start", "end"], + "properties": { + "userId": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "oneTime": { + "type": "boolean" + }, + "start": { + "type": "string", + "format": "date-time" + }, + "end": { + "type": "string", + "format": "date-time" + }, + "expiry": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateStatusPageBody": { + "type": "object", + "required": ["companyName", "url", "timezone", "color", "theme", "monitors"], + "properties": { + "companyName": { "type": "string" }, + "url": { "type": "string" }, + "timezone": { "type": "string" }, + "color": { "type": "string" }, + "theme": { "type": "string" }, + "monitors": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } +} diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100755 index 000000000..7a9d8d026 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,8331 @@ +{ + "name": "server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "server", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "axios": "^1.7.2", + "bcrypt": "5.1.1", + "bullmq": "5.41.2", + "compression": "1.8.0", + "cors": "^2.8.5", + "dockerode": "4.0.4", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "handlebars": "^4.7.8", + "helmet": "^8.0.0", + "ioredis": "^5.4.2", + "jmespath": "^0.16.0", + "joi": "^17.13.1", + "jsonwebtoken": "9.0.2", + "mailersend": "^2.2.0", + "mjml": "^5.0.0-alpha.4", + "mongoose": "^8.3.3", + "multer": "1.4.5-lts.1", + "nodemailer": "^6.9.14", + "ping": "0.4.4", + "sharp": "0.33.5", + "ssl-checker": "2.0.10", + "swagger-ui-express": "5.0.1", + "winston": "^3.13.0" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "c8": "10.1.3", + "chai": "5.2.0", + "eslint": "^9.17.0", + "eslint-plugin-mocha": "^10.5.0", + "esm": "3.2.25", + "globals": "^15.14.0", + "mocha": "11.1.0", + "nodemon": "3.1.9", + "prettier": "^3.3.3", + "sinon": "19.0.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz", + "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==", + "license": "Apache-2.0" + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.1.tgz", + "integrity": "sha512-W+a0/JpU28AqH4IKtwUPcEUnUyXMDLALcn5/JLczGGT9fHE2sIby/xP/oQnx3nxkForzgzPy201RAKcB4xPAFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", + "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", + "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", + "dev": true, + "dependencies": { + "@eslint/object-schema": "^2.1.5", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/core": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", + "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", + "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.20.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.20.0.tgz", + "integrity": "sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", + "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", + "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.10.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.12.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.12.5.tgz", + "integrity": "sha512-d3iiHxdpg5+ZcJ6jnDSOT8Z0O0VMVGy34jAnYLUX8yd36b1qn8f1TwOA/Lc7TsOh03IkPJ38eGI5qD2EjNkoEA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.13.tgz", + "integrity": "sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", + "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "dev": true, + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", + "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.10.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.4.tgz", + "integrity": "sha512-99l6wv4HEzBQhvaU/UGoeBoCK61SCROQaCCGyQSgX2tEQ3rKkNZ2S7CEWnS/4s1LV+8ODdK21UeyR1fHP2mXug==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz", + "integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/browserslist": { + "version": "4.24.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz", + "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bson": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.1.tgz", + "integrity": "sha512-P92xmHDQjSKPLHqFxefqMxASNq/aWJMEZugpCjf+AF/pgcUpMMQCg7t7+ewko0/u8AapvF3luf/FoehddEK+sA==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bullmq": { + "version": "5.41.2", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.41.2.tgz", + "integrity": "sha512-wqsUIHW2Td86mTKTepqQpKLLUtP4gmX89bUO1YL2fAorxwj3da1GYtroGZMCg/zgB/+zMRsbylL6DHyMUWX7fA==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.9.0", + "ioredis": "^5.4.1", + "msgpackr": "^1.11.2", + "node-abort-controller": "^3.1.1", + "semver": "^7.5.4", + "tslib": "^2.0.0", + "uuid": "^9.0.0" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c8": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^7.0.1", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001690", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", + "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "license": "MIT" + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "node_modules/colorspace/node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/colorspace/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/colorspace/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", + "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.0.2", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-declaration-sorter": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz", + "integrity": "sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow==", + "license": "ISC", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-select": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", + "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-7.0.6.tgz", + "integrity": "sha512-54woqx8SCbp8HwvNZYn68ZFAepuouZW4lTwiMVnBErM3VkO7/Sd4oTOt3Zz3bPx3kxQ36aISppyXj2Md4lg8bw==", + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^7.0.6", + "lilconfig": "^3.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-default": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cssnano-preset-default/-/cssnano-preset-default-7.0.6.tgz", + "integrity": "sha512-ZzrgYupYxEvdGGuqL+JKOY70s7+saoNlHSCK/OGn1vB2pQK8KSET8jvenzItcY+kA7NoWvfbb/YhlzuzNKjOhQ==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "css-declaration-sorter": "^7.2.0", + "cssnano-utils": "^5.0.0", + "postcss-calc": "^10.0.2", + "postcss-colormin": "^7.0.2", + "postcss-convert-values": "^7.0.4", + "postcss-discard-comments": "^7.0.3", + "postcss-discard-duplicates": "^7.0.1", + "postcss-discard-empty": "^7.0.0", + "postcss-discard-overridden": "^7.0.0", + "postcss-merge-longhand": "^7.0.4", + "postcss-merge-rules": "^7.0.4", + "postcss-minify-font-values": "^7.0.0", + "postcss-minify-gradients": "^7.0.0", + "postcss-minify-params": "^7.0.2", + "postcss-minify-selectors": "^7.0.4", + "postcss-normalize-charset": "^7.0.0", + "postcss-normalize-display-values": "^7.0.0", + "postcss-normalize-positions": "^7.0.0", + "postcss-normalize-repeat-style": "^7.0.0", + "postcss-normalize-string": "^7.0.0", + "postcss-normalize-timing-functions": "^7.0.0", + "postcss-normalize-unicode": "^7.0.2", + "postcss-normalize-url": "^7.0.0", + "postcss-normalize-whitespace": "^7.0.0", + "postcss-ordered-values": "^7.0.1", + "postcss-reduce-initial": "^7.0.2", + "postcss-reduce-transforms": "^7.0.0", + "postcss-svgo": "^7.0.1", + "postcss-unique-selectors": "^7.0.3" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-utils": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cssnano-utils/-/cssnano-utils-5.0.0.tgz", + "integrity": "sha512-Uij0Xdxc24L6SirFr25MlwC2rCFX6scyUmuKpzI+JQ7cyqDEwD42fJ0xfB3yLfOnRDU5LKGgjQ9FA6LYh76GWQ==", + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/docker-modem": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.6.tgz", + "integrity": "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.4.tgz", + "integrity": "sha512-6GYP/EdzEY50HaOxTVTJ2p+mB5xDHTMJhS+UoGrVyS6VC+iQRh7kZ4FRpUYq6nziby7hPqWhOrFFUFTMUZJJ5w==", + "license": "Apache-2.0", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.6", + "protobufjs": "^7.3.2", + "tar-fs": "~2.0.1", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.74", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.74.tgz", + "integrity": "sha512-ck3//9RC+6oss/1Bh9tiAVFy5vfSKbRHAFh7Z3/eTRkEqJeWgymloShB17Vg3Z4nmDNp35vAd1BZ6CMW4Wt6Iw==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", + "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-3.0.0.tgz", + "integrity": "sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.20.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.20.1.tgz", + "integrity": "sha512-m1mM33o6dBUjxl2qb6wv6nGNwCAsns1eKtaQ4l/NPHeTvhiUPbtdfMyktxN4B3fgHIgsYh1VT3V9txblpQHq+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.11.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.20.0", + "@eslint/plugin-kit": "^0.2.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-mocha": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-10.5.0.tgz", + "integrity": "sha512-F2ALmQVPT1GoP27O1JTZGrV9Pqg8k79OeIuvw63UxMtQKREZtmkK1NFgkZQ2TW7L2JSSFKHFPTtHu5z8R9QNRw==", + "dev": true, + "dependencies": { + "eslint-utils": "^3.0.0", + "globals": "^13.24.0", + "rambda": "^7.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-mocha/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", + "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/@eslint/core": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.11.0.tgz", + "integrity": "sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/esm": { + "version": "3.2.25", + "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", + "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.2.tgz", + "integrity": "sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==", + "dev": true + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/gauge/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", + "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "dunder-proto": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/helmet": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.0.0.tgz", + "integrity": "sha512-VyusHLEIIO5mjQPUI1wpOAEu+wl6Q0998jzTxqUYGE45xCIcAxy3MsbEK/yyJUJ3ADeMoB6MornPH6GMWAf+Pw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlnano": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/htmlnano/-/htmlnano-2.1.1.tgz", + "integrity": "sha512-kAERyg/LuNZYmdqgCdYvugyLWNFAm8MWXpQMz1pLpetmCbFwoMxvkSoaAMlFrOC4OKTWI4KlZGT/RsNxg4ghOw==", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^9.0.0", + "posthtml": "^0.16.5", + "timsort": "^0.3.0" + }, + "peerDependencies": { + "cssnano": "^7.0.0", + "postcss": "^8.3.11", + "purgecss": "^6.0.0", + "relateurl": "^0.2.7", + "srcset": "5.0.1", + "svgo": "^3.0.2", + "terser": "^5.10.0", + "uncss": "^0.17.3" + }, + "peerDependenciesMeta": { + "cssnano": { + "optional": true + }, + "postcss": { + "optional": true + }, + "purgecss": { + "optional": true + }, + "relateurl": { + "optional": true + }, + "srcset": { + "optional": true + }, + "svgo": { + "optional": true + }, + "terser": { + "optional": true + }, + "uncss": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.5.0.tgz", + "integrity": "sha512-7CutT89g23FfSa8MDoIFs2GYYa0PaNiW/OrT+nRyjRXHDZd17HmIgy+reOQ/yhh72NznNjGuS8kbCAcA4Ro4mw==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-json": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-json/-/is-json-2.0.1.tgz", + "integrity": "sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==", + "license": "ISC" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isomorphic-unfetch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/isomorphic-unfetch/-/isomorphic-unfetch-3.1.0.tgz", + "integrity": "sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "unfetch": "^4.2.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/joi": { + "version": "17.13.3", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", + "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/juice": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/juice/-/juice-11.0.0.tgz", + "integrity": "sha512-sGF8hPz9/Wg+YXbaNDqc1Iuoaw+J/P9lBHNQKXAGc9pPNjCd4fyPai0Zxj7MRtdjMr0lcgk5PjEIkP2b8R9F3w==", + "license": "MIT", + "dependencies": { + "cheerio": "^1.0.0", + "commander": "^12.1.0", + "mensch": "^0.3.4", + "slick": "^1.12.2", + "web-resource-inliner": "^7.0.0" + }, + "bin": { + "juice": "bin/juice" + }, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/juice/node_modules/cheerio": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=18.17" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/juice/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/long": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", + "integrity": "sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==", + "license": "Apache-2.0" + }, + "node_modules/loupe": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.2.tgz", + "integrity": "sha512-23I4pFZHmAemUnz8WZXbYRSKYj801VDaNv9ETuMh7IrMc7VuVVSo+Z9iLE3ni30+U48iDWfi30d3twAXBYmnCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/luxon": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/mailersend": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/mailersend/-/mailersend-2.3.0.tgz", + "integrity": "sha512-pe498Ry7VaAb+oqcYqmPw1V7FlECG/mcqahQ3SiK54en4ZkyRwjyxoQwA9VU4s3npB+I44LlQGUudObZQe4/jA==", + "license": "MIT", + "dependencies": { + "gaxios": "^5.0.1", + "isomorphic-unfetch": "^3.1.0", + "qs": "^6.11.0" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/mensch": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/mensch/-/mensch-0.3.4.tgz", + "integrity": "sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mjml": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml/-/mjml-5.0.0-alpha.6.tgz", + "integrity": "sha512-unizId6dKTQSHq1nGnRQqe58kpD7VJu9p+vfMsKO4911/+VCrxkFe0oiwS7Q6XA3rc224MVChO2Mvueyeh16jg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "mjml-cli": "5.0.0-alpha.6", + "mjml-core": "5.0.0-alpha.6", + "mjml-preset-core": "5.0.0-alpha.6", + "mjml-validator": "5.0.0-alpha.6" + }, + "bin": { + "mjml": "bin/mjml" + } + }, + "node_modules/mjml-accordion": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-accordion/-/mjml-accordion-5.0.0-alpha.6.tgz", + "integrity": "sha512-K7FS8HZQsfUNkvwUWMjCmaEEtHRrqEDddyaVoOE6+ir4H6aWAJUZqp1j/USQsYwMI3YAS3UDpqQEOagG4pRDqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-body": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-body/-/mjml-body-5.0.0-alpha.6.tgz", + "integrity": "sha512-otkINnYsBVbKSYqOz/FmhQruOpYzT10w9+UGcOZdBwv+UqDoKHXAD8QeSFZxr7fDSFhc6qeFAKNSY5uciT1ZuQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-button": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-button/-/mjml-button-5.0.0-alpha.6.tgz", + "integrity": "sha512-Z+0J6F2hk7QxKydlABna/vcGtTl7WfMWMLH74x9T1VgNylUAZ2TaWOUAo1AbWICgYBmZfNw1UkDj4EFvb+B1LA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-carousel": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-carousel/-/mjml-carousel-5.0.0-alpha.6.tgz", + "integrity": "sha512-G2n6D6smGmQEKGByjR52UD14D/0gtVokaEI4wjG7UjisSGYGbDXJW4thfDv2RdwIoozXyGhJD3vRuHsoCPeFnw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-cli": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-cli/-/mjml-cli-5.0.0-alpha.6.tgz", + "integrity": "sha512-mHmw5MCLNImcUUnYwl1yPF3meAd2EApnriMp8DTEN3zCqXsMg1RV8EB1npp4Camwz8HvVMTvEjSoVotwNWxAMg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "chokidar": "^3.0.0", + "glob": "^10.3.10", + "lodash": "^4.17.21", + "minimatch": "^9.0.3", + "mjml-core": "5.0.0-alpha.6", + "mjml-parser-xml": "5.0.0-alpha.6", + "mjml-validator": "5.0.0-alpha.6", + "yargs": "^17.7.2" + }, + "bin": { + "mjml-cli": "bin/mjml" + } + }, + "node_modules/mjml-column": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-column/-/mjml-column-5.0.0-alpha.6.tgz", + "integrity": "sha512-FWpmyCH1kzV3g0P6po1OMVOLTgQXDTbo6z30ew+CylUYnn1w5Vh30T1ZvSPYQDc2jGqjsX1zuWO/JUjOJ5B2RQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-core": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-core/-/mjml-core-5.0.0-alpha.6.tgz", + "integrity": "sha512-rrGr+xrOCnJ+3V/+LeqA4BCp7jrXiRq0q37FlmSs+etE86yNqMMFMgEFEbeGYTTQO07WuqXlhPSuq85ucAoivg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "cheerio": "1.0.0-rc.12", + "cssnano": "^7.0.1", + "detect-node": "^2.0.4", + "htmlnano": "^2.1.1", + "juice": "^11.0.0", + "lodash": "^4.17.21", + "mjml-parser-xml": "5.0.0-alpha.6", + "mjml-validator": "5.0.0-alpha.6", + "postcss": "^8.4.33", + "prettier": "^3.2.4" + } + }, + "node_modules/mjml-divider": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-divider/-/mjml-divider-5.0.0-alpha.6.tgz", + "integrity": "sha512-lw0rQNn2Y4LRcF/ad4JUjVRsyXhYXdaos6yOXPFjRN8aK1T4p7XctZyP5RMl7m8YfXTNxxyN//hN3bQI5DwvgQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-group": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-group/-/mjml-group-5.0.0-alpha.6.tgz", + "integrity": "sha512-No9EeJC9GSpqmxfo0laCmUU0w0xhHb3HaliFJPh7YlzObyRE3nvawHtPYpfNW3lgYNl0U7oYMlN9AQum/DaW2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-head": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-head/-/mjml-head-5.0.0-alpha.6.tgz", + "integrity": "sha512-owBfZUcwHV2Wjow32QkRNZClbVHIAthacskvowAODUlOfTG1Xj0czlAM6iCG73cSqmZFuocOD9hy47y0l00Qeg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-head-attributes": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-head-attributes/-/mjml-head-attributes-5.0.0-alpha.6.tgz", + "integrity": "sha512-CW/E+IEw3MOLTWkTMbN6egPwoeg22Iup+fICAH9rmITD6SBJ6f37/MFiACSmpkDQIlWdFokBFFKpikbPWFJKQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-head-breakpoint": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-head-breakpoint/-/mjml-head-breakpoint-5.0.0-alpha.6.tgz", + "integrity": "sha512-3RsHM0l3VU+NhG4MAsXevLH50s/uOJqbZznGVKkgEBsodKDhFK2ndlmUWZ5ywDO6H2g/qDrZse4c1K43uGoyzA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-head-font": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-head-font/-/mjml-head-font-5.0.0-alpha.6.tgz", + "integrity": "sha512-+HZ/Ppd/hfFgob9M27Tz9qio/vWwyTwjo40PsYSI6zXKmIUHC5icBcHaezUKMDGadgd+11aXlPLVfs6zGb0hVg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-head-html-attributes": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-head-html-attributes/-/mjml-head-html-attributes-5.0.0-alpha.6.tgz", + "integrity": "sha512-INI3irUFHozLfJCW0pi/499DhlwqpsFEoGQ++NdHlFH87hbQ2XTNlrTHWpgjhSuiJkRJinPYHphRuNyM3KLyVw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-head-preview": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-head-preview/-/mjml-head-preview-5.0.0-alpha.6.tgz", + "integrity": "sha512-HGLcYmJ7q4aYD5VNTUKIvk6wvpXUawI5j4yl9m+DEhk2ptKYmayxLpjiAvH+LioNuPh0zTCGt7U9f5Ja0948gQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-head-style": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-head-style/-/mjml-head-style-5.0.0-alpha.6.tgz", + "integrity": "sha512-yQcHIvZGH641irNt21r4hzODp5rEI/qKJRvGVJ6vzyGm7poYbaoxhK7Re9cRu+xZxLpkiTRgf1DYFjyZN45SiQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-head-title": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-head-title/-/mjml-head-title-5.0.0-alpha.6.tgz", + "integrity": "sha512-m6dGCAItgobZSJ7wlETFPth6rU0+617ZVk1f88Mz5I+YVuOgaAqdNDQWdue5Uj7B19c55NmtdZwsgOKmhHuwow==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-hero": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-hero/-/mjml-hero-5.0.0-alpha.6.tgz", + "integrity": "sha512-OLOKTK/VW4fQOH8yQz1e5kKHfyF0heECghPhmxgnEHigJLbaJy/rmb4x4aq0Lr74Bme8hsMjkR+oktGej1c4iQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-image": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-image/-/mjml-image-5.0.0-alpha.6.tgz", + "integrity": "sha512-NENjbEOzobM0iQLlxJUNuqiNlFSP5E1DfiP37bY8smYmpVe4MXi5HC5ajTH5ZBKWsTN4/UVZL7t+ZSqniTnS5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-navbar": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-navbar/-/mjml-navbar-5.0.0-alpha.6.tgz", + "integrity": "sha512-bj328dixWJqox9CaA/rvIKsTZ7c5g1mN9B0gL2vuzVMXgX4qnXkzIfN2hw98TdffaKGsKXC6N25iIDVocj1RrQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-parser-xml": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-parser-xml/-/mjml-parser-xml-5.0.0-alpha.6.tgz", + "integrity": "sha512-2gxzFJXBFq6l8f2/HlDq7MBLtWQja0KPSqePxPBOXmNLjqw6bdSflnQu+OhWVptdywuHm907LNIQVIq0wSo2FQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "detect-node": "2.1.0", + "htmlparser2": "^9.1.0", + "lodash": "^4.17.21" + } + }, + "node_modules/mjml-parser-xml/node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/mjml-preset-core": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-preset-core/-/mjml-preset-core-5.0.0-alpha.6.tgz", + "integrity": "sha512-6K9sPXlxQfe9Vx/8/wAFW2Sy6ImWU1AP37VG08Ge7Mj117cNeKk3S1piagbj6LpyTd7E7AueO8G+xzvmC5YBBw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "mjml-accordion": "5.0.0-alpha.6", + "mjml-body": "5.0.0-alpha.6", + "mjml-button": "5.0.0-alpha.6", + "mjml-carousel": "5.0.0-alpha.6", + "mjml-column": "5.0.0-alpha.6", + "mjml-divider": "5.0.0-alpha.6", + "mjml-group": "5.0.0-alpha.6", + "mjml-head": "5.0.0-alpha.6", + "mjml-head-attributes": "5.0.0-alpha.6", + "mjml-head-breakpoint": "5.0.0-alpha.6", + "mjml-head-font": "5.0.0-alpha.6", + "mjml-head-html-attributes": "5.0.0-alpha.6", + "mjml-head-preview": "5.0.0-alpha.6", + "mjml-head-style": "5.0.0-alpha.6", + "mjml-head-title": "5.0.0-alpha.6", + "mjml-hero": "5.0.0-alpha.6", + "mjml-image": "5.0.0-alpha.6", + "mjml-navbar": "5.0.0-alpha.6", + "mjml-raw": "5.0.0-alpha.6", + "mjml-section": "5.0.0-alpha.6", + "mjml-social": "5.0.0-alpha.6", + "mjml-spacer": "5.0.0-alpha.6", + "mjml-table": "5.0.0-alpha.6", + "mjml-text": "5.0.0-alpha.6", + "mjml-wrapper": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-raw": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-raw/-/mjml-raw-5.0.0-alpha.6.tgz", + "integrity": "sha512-tY2P+g7bcydVHrk0NPPm2sd+rIB9l5TdCeIdtw+NSOXoHN7vXVezcdr7UYK8TePibzeymh8w9YTWEAIN8fWvLg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-section": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-section/-/mjml-section-5.0.0-alpha.6.tgz", + "integrity": "sha512-gJL+9t9hiAfnXl3gG481Xum1qzURXs6bZ+rZnU47R/070+kinOJTCHt82hFGctjyDi264r/BeM6H6y+m0FIq/w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-social": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-social/-/mjml-social-5.0.0-alpha.6.tgz", + "integrity": "sha512-vPRBYHKeEUwEiVwXLrXgQnWBblizdQTlrZ2V45xEH9+3Jqolw3SlTw+uoZzhcFSBNl7+ytdQgwp7gRC7Bn5IYQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-spacer": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-spacer/-/mjml-spacer-5.0.0-alpha.6.tgz", + "integrity": "sha512-ZyLcoAElvkWnijny2eW0ulX6RxoDQT8AZwv5pJ8O16s0mnfcPAPMaZGTCQZHxPbACKJcCKsIoZrQllQuShf3XA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-table": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-table/-/mjml-table-5.0.0-alpha.6.tgz", + "integrity": "sha512-ze7iNRyT6uX099KxZoV33u1jOlibmfxkUUY/8BXbyfWUIgcwRzb1DfKtIWT/GOqI2WAqh5ZwHd5XZgueimttEA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-text": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-text/-/mjml-text-5.0.0-alpha.6.tgz", + "integrity": "sha512-k7/pUgwZo9ZwnoCVvohsGdpOrJuLpScH183ZxjyPYO8+kOruSFpE2yrGhK/jeLaV4UhdXndNk8dz80wbN0fKjw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6" + } + }, + "node_modules/mjml-validator": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-validator/-/mjml-validator-5.0.0-alpha.6.tgz", + "integrity": "sha512-kL5IJGYXdNN7VunXXJEWyAP40oODRiSNu5NIx5moOXREPHvn7lRqi/XS8MXa48/UBLmKMbltDSQ0DcnESJbodQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9" + } + }, + "node_modules/mjml-wrapper": { + "version": "5.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/mjml-wrapper/-/mjml-wrapper-5.0.0-alpha.6.tgz", + "integrity": "sha512-qJisqqkQrtq4U74BTgRaNoPjpf2PwYff/QxH5yvkFDXK3fYEoNt011K7lrm/+u5wS9mx4msHZN0WA4TI/EeeOw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "lodash": "^4.17.21", + "mjml-core": "5.0.0-alpha.6", + "mjml-section": "5.0.0-alpha.6" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mocha": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.1.0.tgz", + "integrity": "sha512-8uJR5RTC2NgpY3GrYcgpZrsEd9zKbPDpob1RezyR2upGHRQtHWofmzTMzTMSV6dru3tj5Ukt0+Vnq1qhFEEwAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mongodb": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.13.0.tgz", + "integrity": "sha512-KeESYR5TEaFxOuwRqkOm3XOsMqCSkdeDMjaW5u2nuKfX7rqaofp7JQGoi7sVqQcNJTKuveNbzZtWMstb8ABP6Q==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.1", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.1.tgz", + "integrity": "sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.10.1.tgz", + "integrity": "sha512-5beTeBZnJNndRXU9rxPol0JmTWZMAtgkPbooROkGilswvrZALDERY4cJrGZmgGwDS9dl0mxiB7si+Mv9Yms2fg==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.1", + "kareem": "2.6.3", + "mongodb": "~6.13.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.2.tgz", + "integrity": "sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multer": { + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/nan": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz", + "integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", + "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", + "license": "MIT", + "dependencies": { + "entities": "^4.5.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/ping": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/ping/-/ping-0.4.4.tgz", + "integrity": "sha512-56ZMC0j7SCsMMLdOoUg12VZCfj/+ZO+yfOSjaNCRrmZZr6GLbN2X/Ui56T15dI8NhiHckaw5X2pvyfAomanwqQ==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-calc": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-10.0.2.tgz", + "integrity": "sha512-DT/Wwm6fCKgpYVI7ZEWuPJ4az8hiEHtCUeYjZXqU7Ou4QqYh1Df2yCQ7Ca6N7xqKPFkxN3fhf+u9KSoOCJNAjg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12 || ^20.9 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.38" + } + }, + "node_modules/postcss-colormin": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-7.0.2.tgz", + "integrity": "sha512-YntRXNngcvEvDbEjTdRWGU606eZvB5prmHG4BF0yLmVpamXbpsRJzevyy6MZVyuecgzI2AWAlvFi8DAeCqwpvA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-api": "^3.0.0", + "colord": "^2.9.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-convert-values": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-7.0.4.tgz", + "integrity": "sha512-e2LSXPqEHVW6aoGbjV9RsSSNDO3A0rZLCBxN24zvxF25WknMPpX8Dm9UxxThyEbaytzggRuZxaGXqaOhxQ514Q==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-comments": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-7.0.3.tgz", + "integrity": "sha512-q6fjd4WU4afNhWOA2WltHgCbkRhZPgQe7cXF74fuVB/ge4QbM9HEaOIzGSiMvM+g/cOsNAUGdf2JDzqA2F8iLA==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-7.0.1.tgz", + "integrity": "sha512-oZA+v8Jkpu1ct/xbbrntHRsfLGuzoP+cpt0nJe5ED2FQF8n8bJtn7Bo28jSmBYwqgqnqkuSXJfSUEE7if4nClQ==", + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-empty": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-7.0.0.tgz", + "integrity": "sha512-e+QzoReTZ8IAwhnSdp/++7gBZ/F+nBq9y6PomfwORfP7q9nBpK5AMP64kOt0bA+lShBFbBDcgpJ3X4etHg4lzA==", + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-7.0.0.tgz", + "integrity": "sha512-GmNAzx88u3k2+sBTZrJSDauR0ccpE24omTQCVmaTTZFz1du6AasspjaUPMJ2ud4RslZpoFKyf+6MSPETLojc6w==", + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-7.0.4.tgz", + "integrity": "sha512-zer1KoZA54Q8RVHKOY5vMke0cCdNxMP3KBfDerjH/BYHh4nCIh+1Yy0t1pAEQF18ac/4z3OFclO+ZVH8azjR4A==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^7.0.4" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-merge-rules": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-7.0.4.tgz", + "integrity": "sha512-ZsaamiMVu7uBYsIdGtKJ64PkcQt6Pcpep/uO90EpLS3dxJi6OXamIobTYcImyXGoW0Wpugh7DSD3XzxZS9JCPg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^5.0.0", + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-7.0.0.tgz", + "integrity": "sha512-2ckkZtgT0zG8SMc5aoNwtm5234eUx1GGFJKf2b1bSp8UflqaeFzR50lid4PfqVI9NtGqJ2J4Y7fwvnP/u1cQog==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-7.0.0.tgz", + "integrity": "sha512-pdUIIdj/C93ryCHew0UgBnL2DtUS3hfFa5XtERrs4x+hmpMYGhbzo6l/Ir5de41O0GaKVpK1ZbDNXSY6GkXvtg==", + "license": "MIT", + "dependencies": { + "colord": "^2.9.3", + "cssnano-utils": "^5.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-params": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-7.0.2.tgz", + "integrity": "sha512-nyqVLu4MFl9df32zTsdcLqCFfE/z2+f8GE1KHPxWOAmegSo6lpV2GNy5XQvrzwbLmiU7d+fYay4cwto1oNdAaQ==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "cssnano-utils": "^5.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-7.0.4.tgz", + "integrity": "sha512-JG55VADcNb4xFCf75hXkzc1rNeURhlo7ugf6JjiiKRfMsKlDzN9CXHZDyiG6x/zGchpjQS+UAgb1d4nqXqOpmA==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-7.0.0.tgz", + "integrity": "sha512-ABisNUXMeZeDNzCQxPxBCkXexvBrUHV+p7/BXOY+ulxkcjUZO0cp8ekGBwvIh2LbCwnWbyMPNJVtBSdyhM2zYQ==", + "license": "MIT", + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-display-values/-/postcss-normalize-display-values-7.0.0.tgz", + "integrity": "sha512-lnFZzNPeDf5uGMPYgGOw7v0BfB45+irSRz9gHQStdkkhiM0gTfvWkWB5BMxpn0OqgOQuZG/mRlZyJxp0EImr2Q==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-positions/-/postcss-normalize-positions-7.0.0.tgz", + "integrity": "sha512-I0yt8wX529UKIGs2y/9Ybs2CelSvItfmvg/DBIjTnoUSrPxSV7Z0yZ8ShSVtKNaV/wAY+m7bgtyVQLhB00A1NQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-7.0.0.tgz", + "integrity": "sha512-o3uSGYH+2q30ieM3ppu9GTjSXIzOrRdCUn8UOMGNw7Af61bmurHTWI87hRybrP6xDHvOe5WlAj3XzN6vEO8jLw==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-string": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-string/-/postcss-normalize-string-7.0.0.tgz", + "integrity": "sha512-w/qzL212DFVOpMy3UGyxrND+Kb0fvCiBBujiaONIihq7VvtC7bswjWgKQU/w4VcRyDD8gpfqUiBQ4DUOwEJ6Qg==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-7.0.0.tgz", + "integrity": "sha512-tNgw3YV0LYoRwg43N3lTe3AEWZ66W7Dh7lVEpJbHoKOuHc1sLrzMLMFjP8SNULHaykzsonUEDbKedv8C+7ej6g==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-normalize-unicode/-/postcss-normalize-unicode-7.0.2.tgz", + "integrity": "sha512-ztisabK5C/+ZWBdYC+Y9JCkp3M9qBv/XFvDtSw0d/XwfT3UaKeW/YTm/MD/QrPNxuecia46vkfEhewjwcYFjkg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-url": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-7.0.0.tgz", + "integrity": "sha512-+d7+PpE+jyPX1hDQZYG+NaFD+Nd2ris6r8fPTBAjE8z/U41n/bib3vze8x7rKs5H1uEw5ppe9IojewouHk0klQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-normalize-whitespace/-/postcss-normalize-whitespace-7.0.0.tgz", + "integrity": "sha512-37/toN4wwZErqohedXYqWgvcHUGlT8O/m2jVkAfAe9Bd4MzRqlBmXrJRePH0e9Wgnz2X7KymTgTOaaFizQe3AQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-ordered-values": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-7.0.1.tgz", + "integrity": "sha512-irWScWRL6nRzYmBOXReIKch75RRhNS86UPUAxXdmW/l0FcAsg0lvAXQCby/1lymxn/o0gVa6Rv/0f03eJOwHxw==", + "license": "MIT", + "dependencies": { + "cssnano-utils": "^5.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-7.0.2.tgz", + "integrity": "sha512-pOnu9zqQww7dEKf62Nuju6JgsW2V0KRNBHxeKohU+JkHd/GAH5uvoObqFLqkeB2n20mr6yrlWDvo5UBU5GnkfA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-7.0.0.tgz", + "integrity": "sha512-pnt1HKKZ07/idH8cpATX/ujMbtOGhUfE+m8gbqwJE05aTaNw8gbo34a2e3if0xc0dlu75sUOiqvwCGY3fzOHew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-svgo": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-7.0.1.tgz", + "integrity": "sha512-0WBUlSL4lhD9rA5k1e5D8EN5wCEyZD6HJk0jIvRxl+FDVOMlJ7DePHYWGGVc5QRqrJ3/06FTXM0bxjmJpmTPSA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^3.3.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >= 18" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-7.0.3.tgz", + "integrity": "sha512-J+58u5Ic5T1QjP/LDV9g3Cx4CNOgB5vz+kM6+OxHHhFACdcDeKhBXjQmB7fnIZM12YSTvsL0Opwco83DmacW2g==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/posthtml": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.6.tgz", + "integrity": "sha512-JcEmHlyLK/o0uGAlj65vgg+7LIms0xKXe60lcDOTU7oVX/3LuEuLwrQpW3VJ7de5TaFKiW4kWkaIpJL42FEgxQ==", + "license": "MIT", + "dependencies": { + "posthtml-parser": "^0.11.0", + "posthtml-render": "^3.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/posthtml-parser": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.11.0.tgz", + "integrity": "sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==", + "license": "MIT", + "dependencies": { + "htmlparser2": "^7.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/posthtml-parser/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/posthtml-parser/node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/posthtml-parser/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/posthtml-parser/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/posthtml-parser/node_modules/entities": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", + "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/posthtml-parser/node_modules/htmlparser2": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz", + "integrity": "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.2", + "domutils": "^2.8.0", + "entities": "^3.0.1" + } + }, + "node_modules/posthtml-render": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/posthtml-render/-/posthtml-render-3.0.0.tgz", + "integrity": "sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==", + "license": "MIT", + "dependencies": { + "is-json": "^2.0.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", + "integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rambda": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/rambda/-/rambda-7.5.0.tgz", + "integrity": "sha512-y/M9weqWAH4iopRd7EHDEQQvpFPHj1AA3oHozE9tfITHUtTR7Z9PSlIRRG2l1GuW7sefC1cXFfIcF+cgnShdBA==", + "dev": true + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/slick": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/slick/-/slick-1.12.2.tgz", + "integrity": "sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==", + "license": "MIT (http://mootools.net/license.txt)", + "engines": { + "node": "*" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==", + "license": "ISC" + }, + "node_modules/ssh2": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", + "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.20.0" + } + }, + "node_modules/ssl-checker": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/ssl-checker/-/ssl-checker-2.0.10.tgz", + "integrity": "sha512-SS6rrZocToJWHM1p6iVNb583ybB3UqT1fymCHSWuEdXDUqKA6O1D5Fb8KJVmhj3XKXE82IEWcr+idJrc4jUzFQ==", + "license": "MIT" + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylehacks": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-7.0.4.tgz", + "integrity": "sha512-i4zfNrGMt9SB4xRK9L83rlsFCgdGANfeDAYacO1pkqcE7cRHPdWHwnKZVz7WY17Veq/FvyYsRAU++Ga+qDFIww==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "postcss-selector-parser": "^6.1.2" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || >=22.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svgo": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", + "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.2.tgz", + "integrity": "sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz", + "integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.0.0" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/timsort": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", + "integrity": "sha512-qsdtZH+vMoCARQtyod4imc2nIJwg9Cc7lPRrw9CzF8ZKR0khdr8+2nX80PBhET3tcyTtJDxAffGh2rXH4tyU8A==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "6.21.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", + "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "license": "MIT" + }, + "node_modules/unfetch": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz", + "integrity": "sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/valid-data-url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/valid-data-url/-/valid-data-url-3.0.1.tgz", + "integrity": "sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-resource-inliner": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-7.0.0.tgz", + "integrity": "sha512-NlfnGF8MY9ZUwFjyq3vOUBx7KwF8bmE+ywR781SB0nWB6MoMxN4BA8gtgP1KGTZo/O/AyWJz7HZpR704eaj4mg==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "escape-goat": "^3.0.0", + "htmlparser2": "^5.0.0", + "mime": "^2.4.6", + "valid-data-url": "^3.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/dom-serializer/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domhandler": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.3.0.tgz", + "integrity": "sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.0.1" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/domutils/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/htmlparser2": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-5.0.1.tgz", + "integrity": "sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^3.3.0", + "domutils": "^2.4.2", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/fb55/htmlparser2?sponsor=1" + } + }, + "node_modules/web-resource-inliner/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==", + "license": "MIT", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wide-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wide-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wide-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100755 index 000000000..89149a80d --- /dev/null +++ b/server/package.json @@ -0,0 +1,57 @@ +{ + "name": "server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "test": "c8 mocha", + "dev": "nodemon index.js", + "lint": "eslint .", + "lint-fix": "eslint --fix .", + "format": "prettier --write .", + "format-check": "prettier --check ." + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "axios": "^1.7.2", + "bcrypt": "5.1.1", + "bullmq": "5.41.2", + "compression": "1.8.0", + "cors": "^2.8.5", + "dockerode": "4.0.4", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "handlebars": "^4.7.8", + "helmet": "^8.0.0", + "ioredis": "^5.4.2", + "jmespath": "^0.16.0", + "joi": "^17.13.1", + "jsonwebtoken": "9.0.2", + "mailersend": "^2.2.0", + "mjml": "^5.0.0-alpha.4", + "mongoose": "^8.3.3", + "multer": "1.4.5-lts.1", + "nodemailer": "^6.9.14", + "ping": "0.4.4", + "sharp": "0.33.5", + "ssl-checker": "2.0.10", + "swagger-ui-express": "5.0.1", + "winston": "^3.13.0" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "c8": "10.1.3", + "chai": "5.2.0", + "eslint": "^9.17.0", + "eslint-plugin-mocha": "^10.5.0", + "esm": "3.2.25", + "globals": "^15.14.0", + "mocha": "11.1.0", + "nodemon": "3.1.9", + "prettier": "^3.3.3", + "sinon": "19.0.2" + } +} \ No newline at end of file diff --git a/server/routes/announcementsRoute.js b/server/routes/announcementsRoute.js new file mode 100755 index 000000000..0b234cb96 --- /dev/null +++ b/server/routes/announcementsRoute.js @@ -0,0 +1,38 @@ +import { Router } from "express"; +import { verifyJWT } from "../middleware/verifyJWT.js"; +import { isAllowed } from "../middleware/isAllowed.js"; + +class AnnouncementRoutes { + constructor(controller) { + this.router = Router(); + this.announcementController = controller; + this.initRoutes(); + } + + initRoutes() { + /** + * @route POST / + * @desc Create a new announcement + * @access Private (Requires JWT verification) + */ + this.router.post( + "/", + verifyJWT, + isAllowed(["admin", "superadmin"]), + this.announcementController.createAnnouncement + ); + + /** + * @route GET / + * @desc Get announcements + * @access Public + */ + this.router.get("/", this.announcementController.getAnnouncement); + } + + getRouter() { + return this.router; + } +} + +export default AnnouncementRoutes; diff --git a/server/routes/authRoute.js b/server/routes/authRoute.js new file mode 100755 index 000000000..aabd8de89 --- /dev/null +++ b/server/routes/authRoute.js @@ -0,0 +1,59 @@ +import { Router } from "express"; +import { verifyJWT } from "../middleware/verifyJWT.js"; +import { verifyOwnership } from "../middleware/verifyOwnership.js"; +import { isAllowed } from "../middleware/isAllowed.js"; +import multer from "multer"; +import User from "../db/models/User.js"; + +const upload = multer(); + +class AuthRoutes { + constructor(authController) { + this.router = Router(); + this.authController = authController; + this.initRoutes(); + } + + initRoutes() { + this.router.post( + "/register", + upload.single("profileImage"), + this.authController.registerUser + ); + this.router.post("/login", this.authController.loginUser); + + this.router.put( + "/user/:userId", + upload.single("profileImage"), + verifyJWT, + this.authController.editUser + ); + + this.router.get("/users/superadmin", this.authController.checkSuperadminExists); + + this.router.get( + "/users", + verifyJWT, + isAllowed(["admin", "superadmin"]), + this.authController.getAllUsers + ); + + this.router.delete( + "/user/:userId", + verifyJWT, + verifyOwnership(User, "userId"), + this.authController.deleteUser + ); + + // Recovery routes + this.router.post("/recovery/request", this.authController.requestRecovery); + this.router.post("/recovery/validate", this.authController.validateRecovery); + this.router.post("/recovery/reset/", this.authController.resetPassword); + } + + getRouter() { + return this.router; + } +} + +export default AuthRoutes; diff --git a/server/routes/checkRoute.js b/server/routes/checkRoute.js new file mode 100755 index 000000000..bab45e4d4 --- /dev/null +++ b/server/routes/checkRoute.js @@ -0,0 +1,46 @@ +import { Router } from "express"; +import { verifyOwnership } from "../middleware/verifyOwnership.js"; +import { isAllowed } from "../middleware/isAllowed.js"; +import Monitor from "../db/models/Monitor.js"; + +class CheckRoutes { + constructor(checkController) { + this.router = Router(); + this.checkController = checkController; + this.initRoutes(); + } + + initRoutes() { + this.router.get("/:monitorId", this.checkController.getChecksByMonitor); + this.router.post( + "/:monitorId", + verifyOwnership(Monitor, "monitorId"), + this.checkController.createCheck + ); + this.router.delete( + "/:monitorId", + verifyOwnership(Monitor, "monitorId"), + this.checkController.deleteChecks + ); + + this.router.get("/team/:teamId", this.checkController.getChecksByTeam); + + this.router.delete( + "/team/:teamId", + isAllowed(["admin", "superadmin"]), + this.checkController.deleteChecksByTeamId + ); + + this.router.put( + "/team/ttl", + isAllowed(["admin", "superadmin"]), + this.checkController.updateChecksTTL + ); + } + + getRouter() { + return this.router; + } +} + +export default CheckRoutes; diff --git a/server/routes/diagnosticRoute.js b/server/routes/diagnosticRoute.js new file mode 100755 index 000000000..c5ee7cdeb --- /dev/null +++ b/server/routes/diagnosticRoute.js @@ -0,0 +1,28 @@ +import { Router } from "express"; + +class DiagnosticRoutes { + constructor(diagnosticController) { + this.router = Router(); + this.diagnosticController = diagnosticController; + this.initRoutes(); + } + initRoutes() { + this.router.get( + "/db/execution-stats/:monitorId", + this.diagnosticController.getDistributedUptimeDbExecutionStats + ); + + this.router.get( + "/db/get-monitors-by-team-id/:teamId", + this.diagnosticController.getMonitorsByTeamIdExecutionStats + ); + + this.router.post("/db/stats", this.diagnosticController.getDbStats); + } + + getRouter() { + return this.router; + } +} + +export default DiagnosticRoutes; diff --git a/server/routes/distributedUptimeRoute.js b/server/routes/distributedUptimeRoute.js new file mode 100755 index 000000000..51e8086cb --- /dev/null +++ b/server/routes/distributedUptimeRoute.js @@ -0,0 +1,44 @@ +import { Router } from "express"; +import { verifyJWT } from "../middleware/verifyJWT.js"; +class DistributedUptimeRoutes { + constructor(distributedUptimeController) { + this.router = Router(); + this.distributedUptimeController = distributedUptimeController; + this.initRoutes(); + } + initRoutes() { + this.router.post("/callback", this.distributedUptimeController.resultsCallback); + + this.router.get( + "/monitors/:teamId/initial", + verifyJWT, + this.distributedUptimeController.getDistributedUptimeMonitors + ); + + this.router.get( + "/monitors/:teamId", + this.distributedUptimeController.subscribeToDistributedUptimeMonitors + ); + + this.router.get( + "/monitors/details/:monitorId/initial", + verifyJWT, + this.distributedUptimeController.getDistributedUptimeMonitorDetails + ); + this.router.get( + "/monitors/details/public/:monitorId/initial", + this.distributedUptimeController.getDistributedUptimeMonitorDetails + ); + + this.router.get( + "/monitors/details/:monitorId", + this.distributedUptimeController.subscribeToDistributedUptimeMonitorDetails + ); + } + + getRouter() { + return this.router; + } +} + +export default DistributedUptimeRoutes; diff --git a/server/routes/inviteRoute.js b/server/routes/inviteRoute.js new file mode 100755 index 000000000..301959389 --- /dev/null +++ b/server/routes/inviteRoute.js @@ -0,0 +1,29 @@ +import { Router } from "express"; +import { verifyJWT } from "../middleware/verifyJWT.js"; +import { isAllowed } from "../middleware/isAllowed.js"; + +class InviteRoutes { + constructor(inviteController) { + this.router = Router(); + this.inviteController = inviteController; + this.initRoutes(); + } + + initRoutes() { + this.router.post( + "/", + isAllowed(["admin", "superadmin"]), + verifyJWT, + this.inviteController.getInviteToken + ); + + this.router.post("/send", this.inviteController.sendInviteEmail); + this.router.post("/verify", this.inviteController.inviteVerifyController); + } + + getRouter() { + return this.router; + } +} + +export default InviteRoutes; diff --git a/server/routes/maintenanceWindowRoute.js b/server/routes/maintenanceWindowRoute.js new file mode 100755 index 000000000..2665c2bb8 --- /dev/null +++ b/server/routes/maintenanceWindowRoute.js @@ -0,0 +1,37 @@ +import { Router } from "express"; +import { verifyOwnership } from "../middleware/verifyOwnership.js"; +import Monitor from "../db/models/Monitor.js"; + +class MaintenanceWindowRoutes { + constructor(maintenanceWindowController) { + this.router = Router(); + this.maintenanceWindowController = maintenanceWindowController; + this.initRoutes(); + } + initRoutes() { + this.router.post("/", this.maintenanceWindowController.createMaintenanceWindows); + + this.router.get( + "/monitor/:monitorId", + verifyOwnership(Monitor, "monitorId"), + this.maintenanceWindowController.getMaintenanceWindowsByMonitorId + ); + + this.router.get( + "/team/", + this.maintenanceWindowController.getMaintenanceWindowsByTeamId + ); + + this.router.get("/:id", this.maintenanceWindowController.getMaintenanceWindowById); + + this.router.put("/:id", this.maintenanceWindowController.editMaintenanceWindow); + + this.router.delete("/:id", this.maintenanceWindowController.deleteMaintenanceWindow); + } + + getRouter() { + return this.router; + } +} + +export default MaintenanceWindowRoutes; diff --git a/server/routes/monitorRoute.js b/server/routes/monitorRoute.js new file mode 100755 index 000000000..36eed3911 --- /dev/null +++ b/server/routes/monitorRoute.js @@ -0,0 +1,103 @@ +import { Router } from "express"; +import { isAllowed } from "../middleware/isAllowed.js"; +import { fetchMonitorCertificate } from "../controllers/controllerUtils.js"; + +class MonitorRoutes { + constructor(monitorController) { + this.router = Router(); + this.monitorController = monitorController; + this.initRoutes(); + } + + initRoutes() { + this.router.get("/", this.monitorController.getAllMonitors); + this.router.get("/uptime", this.monitorController.getAllMonitorsWithUptimeStats); + this.router.get("/stats/:monitorId", this.monitorController.getMonitorStatsById); + this.router.get( + "/hardware/details/:monitorId", + this.monitorController.getHardwareDetailsById + ); + + this.router.get( + "/uptime/details/:monitorId", + this.monitorController.getUptimeDetailsById + ); + this.router.get("/certificate/:monitorId", (req, res, next) => { + this.monitorController.getMonitorCertificate( + req, + res, + next, + fetchMonitorCertificate + ); + }); + this.router.get("/:monitorId", this.monitorController.getMonitorById); + + this.router.get("/team/:teamId", this.monitorController.getMonitorsByTeamId); + + this.router.get( + "/summary/team/:teamId", + this.monitorController.getMonitorsAndSummaryByTeamId + ); + + this.router.get( + "/team/:teamId/with-checks", + this.monitorController.getMonitorsWithChecksByTeamId + ); + + this.router.get( + "/resolution/url", + isAllowed(["admin", "superadmin"]), + this.monitorController.checkEndpointResolution + ); + + this.router.delete( + "/:monitorId", + isAllowed(["admin", "superadmin"]), + this.monitorController.deleteMonitor + ); + + this.router.post( + "/", + isAllowed(["admin", "superadmin"]), + this.monitorController.createMonitor + ); + + this.router.put( + "/:monitorId", + isAllowed(["admin", "superadmin"]), + this.monitorController.editMonitor + ); + + this.router.delete( + "/", + isAllowed(["superadmin"]), + this.monitorController.deleteAllMonitors + ); + + this.router.post( + "/pause/:monitorId", + isAllowed(["admin", "superadmin"]), + this.monitorController.pauseMonitor + ); + + this.router.post( + "/demo", + isAllowed(["admin", "superadmin"]), + this.monitorController.addDemoMonitors + ); + + this.router.post( + "/bulk", + isAllowed(["admin", "superadmin"]), + this.monitorController.createBulkMonitors + ); + + this.router.post("/seed", isAllowed(["superadmin"]), this.monitorController.seedDb); + } + + getRouter() { + return this.router; + } +} + +export default MonitorRoutes; diff --git a/server/routes/notificationRoute.js b/server/routes/notificationRoute.js new file mode 100755 index 000000000..7ea94843e --- /dev/null +++ b/server/routes/notificationRoute.js @@ -0,0 +1,30 @@ +import { Router } from "express"; +import { verifyJWT } from "../middleware/verifyJWT.js"; + +class NotificationRoutes { + constructor(notificationController) { + this.router = Router(); + this.notificationController = notificationController; + this.initializeRoutes(); + } + + initializeRoutes() { + this.router.use(verifyJWT); + + this.router.post( + "/trigger", + this.notificationController.triggerNotification + ); + + this.router.post( + "/test-webhook", + this.notificationController.testWebhook + ); + } + + getRouter() { + return this.router; + } +} + +export default NotificationRoutes; \ No newline at end of file diff --git a/server/routes/queueRoute.js b/server/routes/queueRoute.js new file mode 100755 index 000000000..38ac32a26 --- /dev/null +++ b/server/routes/queueRoute.js @@ -0,0 +1,52 @@ +import { Router } from "express"; +import { isAllowed } from "../middleware/isAllowed.js"; +class QueueRoutes { + constructor(queueController) { + this.router = Router(); + this.queueController = queueController; + this.initRoutes(); + } + initRoutes() { + this.router.get( + "/metrics", + isAllowed(["admin", "superadmin"]), + this.queueController.getMetrics + ); + + this.router.get( + "/jobs", + isAllowed(["admin", "superadmin"]), + this.queueController.getJobs + ); + + this.router.post( + "/jobs", + isAllowed(["admin", "superadmin"]), + this.queueController.addJob + ); + + this.router.post( + "/obliterate", + isAllowed(["admin", "superadmin"]), + this.queueController.obliterateQueue + ); + + this.router.post( + "/flush", + isAllowed(["admin", "superadmin"]), + this.queueController.flushQueue + ); + + this.router.get( + "/health", + isAllowed(["admin", "superadmin"]), + this.queueController.checkQueueHealth + ); + } + + getRouter() { + return this.router; + } +} + +export default QueueRoutes; diff --git a/server/routes/settingsRoute.js b/server/routes/settingsRoute.js new file mode 100755 index 000000000..8da262427 --- /dev/null +++ b/server/routes/settingsRoute.js @@ -0,0 +1,25 @@ +import { Router } from "express"; +import { isAllowed } from "../middleware/isAllowed.js"; + +class SettingsRoutes { + constructor(settingsController) { + this.router = Router(); + this.settingsController = settingsController; + this.initRoutes(); + } + + initRoutes() { + this.router.get("/", this.settingsController.getAppSettings); + this.router.put( + "/", + isAllowed(["superadmin"]), + this.settingsController.updateAppSettings + ); + } + + getRouter() { + return this.router; + } +} + +export default SettingsRoutes; diff --git a/server/routes/statusPageRoute.js b/server/routes/statusPageRoute.js new file mode 100755 index 000000000..bac960b08 --- /dev/null +++ b/server/routes/statusPageRoute.js @@ -0,0 +1,46 @@ +import { Router } from "express"; +import { verifyJWT } from "../middleware/verifyJWT.js"; +import multer from "multer"; +const upload = multer(); + +class StatusPageRoutes { + constructor(statusPageController) { + this.router = Router(); + this.statusPageController = statusPageController; + this.initRoutes(); + } + + initRoutes() { + this.router.get("/", this.statusPageController.getStatusPage); + this.router.get( + "/team/:teamId", + verifyJWT, + this.statusPageController.getStatusPagesByTeamId + ); + this.router.get("/:url", this.statusPageController.getStatusPageByUrl); + this.router.get( + "/distributed/:url", + this.statusPageController.getDistributedStatusPageByUrl + ); + + this.router.post( + "/", + upload.single("logo"), + verifyJWT, + this.statusPageController.createStatusPage + ); + this.router.put( + "/", + upload.single("logo"), + verifyJWT, + this.statusPageController.updateStatusPage + ); + this.router.delete("/:url(*)", verifyJWT, this.statusPageController.deleteStatusPage); + } + + getRouter() { + return this.router; + } +} + +export default StatusPageRoutes; diff --git a/server/service/bufferService.js b/server/service/bufferService.js new file mode 100755 index 000000000..c98dde759 --- /dev/null +++ b/server/service/bufferService.js @@ -0,0 +1,95 @@ +const SERVICE_NAME = "BufferService"; +const BUFFER_TIMEOUT = 1000 * 60 * 1; // 1 minute +const TYPE_MAP = { + http: "checks", + ping: "checks", + port: "checks", + docker: "checks", + pagespeed: "pagespeedChecks", + hardware: "hardwareChecks", + distributed_http: "distributedChecks", +}; + +class BufferService { + constructor({ db, logger }) { + this.db = db; + this.logger = logger; + this.SERVICE_NAME = SERVICE_NAME; + this.buffers = { + checks: [], + pagespeedChecks: [], + hardwareChecks: [], + distributedChecks: [], + }; + this.OPERATION_MAP = { + checks: this.db.createChecks, + pagespeedChecks: this.db.createPageSpeedChecks, + hardwareChecks: this.db.createHardwareChecks, + distributedChecks: this.db.createDistributedChecks, + }; + + this.scheduleNextFlush(); + this.logger.info({ + message: `Buffer service initialized, flushing every ${BUFFER_TIMEOUT / 1000}s`, + service: this.SERVICE_NAME, + method: "constructor", + }); + } + static SERVICE_NAME = SERVICE_NAME; + + addToBuffer({ check, type }) { + try { + this.buffers[TYPE_MAP[type]].push(check); + } catch (error) { + this.logger.error({ + message: error.message, + service: this.SERVICE_NAME, + method: "addToBuffer", + stack: error.stack, + }); + } + } + + scheduleNextFlush() { + this.bufferTimer = setTimeout(async () => { + try { + await this.flushBuffers(); + } catch (error) { + this.logger.error({ + message: `Error in flush cycle: ${error.message}`, + service: this.SERVICE_NAME, + method: "scheduleNextFlush", + stack: error.stack, + }); + } finally { + // Schedule the next flush only after the current one completes + this.scheduleNextFlush(); + } + }, BUFFER_TIMEOUT); + } + async flushBuffers() { + let items = 0; + for (const [bufferName, buffer] of Object.entries(this.buffers)) { + items += buffer.length; + const operation = this.OPERATION_MAP[bufferName]; + if (!operation) { + this.logger.error({ + message: `No operation found for ${bufferName}`, + service: this.SERVICE_NAME, + method: "flushBuffers", + }); + continue; + } + await operation(buffer); + this.buffers[bufferName] = []; + } + this.logger.info({ + message: `Flushed ${items} items`, + service: this.SERVICE_NAME, + method: "flushBuffers", + }); + items = 0; + } +} + +export default BufferService; diff --git a/server/service/emailService.js b/server/service/emailService.js new file mode 100755 index 000000000..ac2e077c0 --- /dev/null +++ b/server/service/emailService.js @@ -0,0 +1,145 @@ +import { fileURLToPath } from "url"; +import path from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const SERVICE_NAME = "EmailService"; + +/** + * Represents an email service that can load templates, build, and send emails. + */ +class EmailService { + static SERVICE_NAME = SERVICE_NAME; + /** + * Constructs an instance of the EmailService, initializing template loaders and the email transporter. + * @param {Object} settingsService - The settings service to get email configuration. + * @param {Object} fs - The file system module. + * @param {Object} path - The path module. + * @param {Function} compile - The Handlebars compile function. + * @param {Function} mjml2html - The MJML to HTML conversion function. + * @param {Object} nodemailer - The nodemailer module. + * @param {Object} logger - The logger module. + */ + constructor(settingsService, fs, path, compile, mjml2html, nodemailer, logger) { + this.settingsService = settingsService; + this.fs = fs; + this.path = path; + this.compile = compile; + this.mjml2html = mjml2html; + this.nodemailer = nodemailer; + this.logger = logger; + + /** + * Loads an email template from the filesystem. + * + * @param {string} templateName - The name of the template to load. + * @returns {Function} A compiled template function that can be used to generate HTML email content. + */ + this.loadTemplate = (templateName) => { + try { + const templatePath = this.path.join( + __dirname, + `../templates/${templateName}.mjml` + ); + const templateContent = this.fs.readFileSync(templatePath, "utf8"); + return this.compile(templateContent); + } catch (error) { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "loadTemplate", + stack: error.stack, + }); + } + }; + + /** + * A lookup object to access preloaded email templates. + * @type {Object.} + * TODO Load less used templates in their respective functions + */ + this.templateLookup = { + welcomeEmailTemplate: this.loadTemplate("welcomeEmail"), + employeeActivationTemplate: this.loadTemplate("employeeActivation"), + noIncidentsThisWeekTemplate: this.loadTemplate("noIncidentsThisWeek"), + serverIsDownTemplate: this.loadTemplate("serverIsDown"), + serverIsUpTemplate: this.loadTemplate("serverIsUp"), + passwordResetTemplate: this.loadTemplate("passwordReset"), + hardwareIncidentTemplate: this.loadTemplate("hardwareIncident"), + }; + + /** + * The email transporter used to send emails. + * @type {Object} + */ + + const { + systemEmailHost, + systemEmailPort, + systemEmailUser, + systemEmailAddress, + systemEmailPassword, + } = this.settingsService.getSettings(); + + const emailConfig = { + host: systemEmailHost, + port: systemEmailPort, + secure: true, + auth: { + user: systemEmailUser || systemEmailAddress, + pass: systemEmailPassword, + }, + }; + + this.transporter = this.nodemailer.createTransport(emailConfig); + } + + /** + * Asynchronously builds and sends an email using a specified template and context. + * + * @param {string} template - The name of the template to use for the email body. + * @param {Object} context - The data context to render the template with. + * @param {string} to - The recipient's email address. + * @param {string} subject - The subject of the email. + * @returns {Promise} A promise that resolves to the messageId of the sent email. + */ + buildAndSendEmail = async (template, context, to, subject) => { + const buildHtml = async (template, context) => { + try { + const mjml = this.templateLookup[template](context); + const html = await this.mjml2html(mjml); + return html.html; + } catch (error) { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "buildAndSendEmail", + stack: error.stack, + }); + } + }; + + const sendEmail = async (to, subject, html) => { + try { + const info = await this.transporter.sendMail({ + to: to, + subject: subject, + html: html, + }); + return info; + } catch (error) { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "sendEmail", + stack: error.stack, + }); + } + }; + const html = await buildHtml(template, context); + const info = await sendEmail(to, subject, html); + return info?.messageId; + }; +} +export default EmailService; diff --git a/server/service/jobQueue.js b/server/service/jobQueue.js new file mode 100755 index 000000000..5674c7524 --- /dev/null +++ b/server/service/jobQueue.js @@ -0,0 +1,818 @@ +import IORedis from "ioredis"; + +const QUEUE_NAMES = ["uptime", "pagespeed", "hardware", "distributed"]; +const SERVICE_NAME = "JobQueue"; +const JOBS_PER_WORKER = 5; +const HEALTH_CHECK_INTERVAL = 10 * 60 * 1000; // 10 minutes +const QUEUE_LOOKUP = { + hardware: "hardware", + http: "uptime", + ping: "uptime", + port: "uptime", + docker: "uptime", + pagespeed: "pagespeed", + distributed_http: "distributed", +}; +const getSchedulerId = (monitor) => `scheduler:${monitor.type}:${monitor._id}`; + +class NewJobQueue { + static SERVICE_NAME = SERVICE_NAME; + + constructor( + db, + statusService, + networkService, + notificationService, + settingsService, + stringService, + logger, + Queue, + Worker + ) { + const settings = settingsService.getSettings() || {}; + const { redisUrl } = settings; + const connection = new IORedis(redisUrl, { maxRetriesPerRequest: null }); + this.queues = {}; + this.workers = {}; + this.lastJobProcessedTime = {}; + + this.connection = connection; + this.db = db; + this.networkService = networkService; + this.statusService = statusService; + this.notificationService = notificationService; + this.settingsService = settingsService; + this.logger = logger; + this.Worker = Worker; + this.stringService = stringService; + + QUEUE_NAMES.forEach((name) => { + const q = new Queue(name, { connection }); + this.lastJobProcessedTime[q.name] = Date.now(); + q.on("error", (error) => { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "queue:error", + stack: error.stack, + }); + }); + this.queues[name] = q; + + this.workers[name] = []; + }); + + this.healthCheckInterval = setInterval(async () => { + try { + const health = await this.checkQueueHealth(); + if (health.stuck === true) { + this.logger.error({ + message: `Queue is stuck: ${health.stuckQueues.join(", ")}`, + service: SERVICE_NAME, + method: "healthCheckInterval", + }); + await this.flushQueue(); + } + } catch (error) { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "periodicHealthCheck", + stack: error.stack, + }); + } + }, HEALTH_CHECK_INTERVAL); + } + + /** + * Initializes job queues by adding jobs for all active monitors + * @async + * @function initJobQueue + * @description Retrieves all monitors from the database and adds jobs for active ones to their respective queues + * @throws {Error} If there's an error retrieving monitors or adding jobs + * @returns {Promise} + */ + async initJobQueue() { + await this.connection.flushall(); + const monitors = await this.db.getAllMonitors(); + await Promise.all( + monitors + .filter((monitor) => monitor.isActive) + .map(async (monitor) => { + try { + await this.addJob(monitor._id, monitor); + } catch (error) { + this.logger.error({ + message: `Failed to add job for monitor ${monitor._id}:`, + service: SERVICE_NAME, + method: "initJobQueue", + stack: error.stack, + }); + } + }) + ); + } + + /** + * Checks if a monitor is currently in a maintenance window + * @async + * @param {string} monitorId - The ID of the monitor to check + * @returns {Promise} Returns true if the monitor is in an active maintenance window, false otherwise + * @throws {Error} If there's an error retrieving maintenance windows from the database + * @description + * Retrieves all maintenance windows for a monitor and checks if any are currently active. + * A maintenance window is considered active if: + * 1. The window is marked as active AND + * 2. Either: + * - Current time falls between start and end times + * - For repeating windows: Current time falls between any repeated interval + */ + async isInMaintenanceWindow(monitorId) { + const maintenanceWindows = await this.db.getMaintenanceWindowsByMonitorId(monitorId); + // Check for active maintenance window: + const maintenanceWindowIsActive = maintenanceWindows.reduce((acc, window) => { + if (window.active) { + const start = new Date(window.start); + const end = new Date(window.end); + const now = new Date(); + const repeatInterval = window.repeat || 0; + + // If start is < now and end > now, we're in maintenance + if (start <= now && end >= now) return true; + + // If maintenance window was set in the past with a repeat, + // we need to advance start and end to see if we are in range + + while (start < now && repeatInterval !== 0) { + start.setTime(start.getTime() + repeatInterval); + end.setTime(end.getTime() + repeatInterval); + if (start <= now && end >= now) { + return true; + } + } + return false; + } + return acc; + }, false); + return maintenanceWindowIsActive; + } + + /** + * Creates a job processing handler for monitor checks + * @function createJobHandler + * @returns {Function} An async function that processes monitor check jobs + * @description + * Creates and returns a job handler that: + * 1. Checks if monitor is in maintenance window + * 2. If not in maintenance, performs network status check + * 3. Updates monitor status in database + * 4. Triggers notifications if status changed + * + * @param {Object} job - The job to process + * @param {Object} job.data - The monitor data + * @param {string} job.data._id - Monitor ID + * @param {string} job.id - Job ID + * + * @throws {Error} Logs errors but doesn't throw them to prevent job failure + * @returns {Promise} Resolves when job processing is complete + */ + createJobHandler() { + return async (job) => { + try { + // Update the last job processed time for this queue + this.lastJobProcessedTime[job.queue.name] = Date.now(); + // Get all maintenance windows for this monitor + await job.updateProgress(0); + const monitorId = job.data._id; + const maintenanceWindowActive = await this.isInMaintenanceWindow(monitorId); + // If a maintenance window is active, we're done + + if (maintenanceWindowActive) { + await job.updateProgress(100); + this.logger.info({ + message: `Monitor ${monitorId} is in maintenance window`, + service: SERVICE_NAME, + method: "createWorker", + }); + return false; + } + + // Get the current status + await job.updateProgress(30); + const networkResponse = await this.networkService.getStatus(job); + if ( + job.data.type === "distributed_http" || + job.data.type === "distributed_test" + ) { + return; + } + // Handle status change + await job.updateProgress(60); + const { monitor, statusChanged, prevStatus } = + await this.statusService.updateStatus(networkResponse); + // Handle notifications + await job.updateProgress(80); + this.notificationService + .handleNotifications({ + ...networkResponse, + monitor, + prevStatus, + statusChanged, + }) + .catch((error) => { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "createJobHandler", + details: `Error sending notifications for job ${job.id}: ${error.message}`, + stack: error.stack, + }); + }); + await job.updateProgress(100); + return true; + } catch (error) { + this.logger.error({ + message: error.message, + service: error.service ?? SERVICE_NAME, + method: error.method ?? "createJobHandler", + details: `Error processing job ${job.id}: ${error.message}`, + stack: error.stack, + }); + throw error; + } + }; + } + + /** + * Creates a new worker for processing jobs in a queue + * @param {Queue} queue - The BullMQ queue to create a worker for + * @returns {Worker} A new BullMQ worker instance + * @description + * Creates and configures a new worker with: + * - Queue-specific job handler + * - Redis connection settings + * - Default worker options + * The worker processes jobs from the specified queue using the job handler + * created by createJobHandler() + * + * @throws {Error} If worker creation fails or connection is invalid + */ + createWorker(queue) { + try { + const worker = new this.Worker(queue.name, this.createJobHandler(), { + connection: this.connection, + concurrency: 5, + stalledInterval: 10000, + maxStalledCount: 1, + lockDuration: 60000, + }); + + worker.on("failed", (job, err) => { + this.logger.error({ + message: `Job ${job.id} failed: ${err.message}`, + service: SERVICE_NAME, + method: "worker:failed", + stack: err.stack, + jobData: job.data, + }); + }); + worker.on("error", (job, err) => { + this.logger.error({ + message: `Job ${job.id} error: ${err.message}`, + service: SERVICE_NAME, + method: "worker:error", + stack: err.stack, + jobData: job.data, + }); + }); + + worker.on("stalled", (jobId) => { + this.logger.warn({ + message: `Job ${jobId} stalled`, + service: SERVICE_NAME, + method: "worker:stalled", + }); + }); + + return worker; + } catch (error) { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "createWorker", + stack: error.stack, + }); + error.service = SERVICE_NAME; + error.method = "createWorker"; + throw error; + } + } + + /** + * Gets stats related to the workers + * This is used for scaling workers right now + * In the future we will likely want to scale based on server performance metrics + * CPU Usage & memory usage, if too high, scale down workers. + * When to scale up? If jobs are taking too long to complete? + * @async + * @returns {Promise} - Returns the worker stats + */ + async getWorkerStats(queue) { + try { + const jobs = await queue.getRepeatableJobs(); + const load = jobs.length / this.workers[queue.name].length; + return { jobs, load }; + } catch (error) { + error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.method === undefined ? (error.method = "getWorkerStats") : null; + throw error; + } + } + + /** + * Scales workers up or down based on queue load + * @async + * @param {Object} workerStats - Statistics about current worker load + * @param {number} workerStats.load - Current load per worker + * @param {Array} workerStats.jobs - Array of current jobs + * @param {Queue} queue - The BullMQ queue to scale workers for + * @returns {Promise} True if scaling occurred, false if no scaling was needed + * @throws {Error} If no workers array exists for the queue + * @description + * Scales workers based on these rules: + * - Maintains minimum of 5 workers + * - Adds workers if load exceeds JOBS_PER_WORKER + * - Removes workers if load is below JOBS_PER_WORKER + * - Creates initial workers if none exist + * Worker scaling is calculated based on excess jobs or excess capacity + */ + async scaleWorkers(workerStats, queue) { + const workers = this.workers[queue.name]; + if (workers === undefined) { + throw new Error(`No workers found for ${queue.name}`); + } + + if (workers.length === 0) { + // There are no workers, need to add one + for (let i = 0; i < 5; i++) { + const worker = this.createWorker(queue); + workers.push(worker); + } + return true; + } + if (workerStats.load > JOBS_PER_WORKER) { + // Find out how many more jobs we have than current workers can handle + const excessJobs = workerStats.jobs.length - workers.length * JOBS_PER_WORKER; + // Divide by jobs/worker to find out how many workers to add + const workersToAdd = Math.ceil(excessJobs / JOBS_PER_WORKER); + for (let i = 0; i < workersToAdd; i++) { + const worker = this.createWorker(queue); + workers.push(worker); + } + return true; + } + + if (workerStats.load < JOBS_PER_WORKER) { + // Find out how much excess capacity we have + const workerCapacity = workers.length * JOBS_PER_WORKER; + const excessCapacity = workerCapacity - workerStats.jobs.length; + // Calculate how many workers to remove + let workersToRemove = Math.floor(excessCapacity / JOBS_PER_WORKER); // Make sure there are always at least 5 + while (workersToRemove > 0 && workers.length > 5) { + const worker = workers.pop(); + workersToRemove--; + await worker.close().catch((error) => { + // Catch the error instead of throwing it + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "scaleWorkers", + stack: error.stack, + }); + }); + } + return true; + } + return false; + } + + /** + * Gets all jobs in the queue. + * + * @async + * @returns {Promise>} + * @throws {Error} - Throws error if getting jobs fails + */ + async getJobs(queue) { + try { + const jobs = await queue.getRepeatableJobs(); + return jobs; + } catch (error) { + error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.method === undefined ? (error.method = "getJobs") : null; + throw error; + } + } + + /** + * Retrieves detailed statistics about jobs and workers for all queues + * @async + * @returns {Promise} Queue statistics object + * @throws {Error} If there's an error retrieving job information + * @description + * Returns an object with statistics for each queue including: + * - List of jobs with their URLs and current states + * - Number of workers assigned to the queue + */ + async getJobStats() { + try { + let stats = {}; + await Promise.all( + QUEUE_NAMES.map(async (name) => { + const queue = this.queues[name]; + const jobs = await queue.getJobs(); + const ret = await Promise.all( + jobs.map(async (job) => { + const state = await job.getState(); + return { url: job.data.url, state, progress: job.progress }; + }) + ); + stats[name] = { jobs: ret, workers: this.workers[name].length }; + }) + ); + return stats; + } catch (error) { + error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.method === undefined ? (error.method = "getJobStats") : null; + throw error; + } + } + + /** + * Adds both immediate and repeatable jobs to the appropriate queue + * @async + * @param {string} jobName - Name identifier for the job + * @param {Object} monitor - Job data and configuration + * @param {string} monitor.type - Type of monitor/queue ('uptime', 'pagespeed', 'hardware') + * @param {string} [monitor.url] - URL to monitor (optional) + * @param {number} [monitor.interval=60000] - Repeat interval in milliseconds + * @param {string} monitor._id - Monitor ID + * @throws {Error} If queue not found for payload type + * @throws {Error} If job addition fails + * @description + * 1. Identifies correct queue based on payload type + * 2. Adds immediate job execution + * 3. Adds repeatable job with specified interval + * 4. Scales workers based on updated queue load + * Jobs are configured with exponential backoff, single attempt, + * and automatic removal on completion + */ + async addJob(jobName, monitor) { + try { + this.logger.info({ + message: `Adding job ${monitor?.url ?? "No URL"}`, + service: SERVICE_NAME, + method: "addJob", + }); + + // Find the correct queue + + const queue = this.queues[QUEUE_LOOKUP[monitor.type]]; + if (queue === undefined) { + throw new Error(`Queue for ${monitor.type} not found`); + } + + const jobTemplate = { + name: jobName, + data: monitor, + opts: { + attempts: 1, + backoff: { + type: "exponential", + delay: 1000, + }, + removeOnComplete: true, + removeOnFail: false, + timeout: 1 * 60 * 1000, + }, + }; + + const schedulerId = getSchedulerId(monitor); + await queue.upsertJobScheduler( + schedulerId, + { every: monitor?.interval ?? 60000 }, + jobTemplate + ); + + const workerStats = await this.getWorkerStats(queue); + await this.scaleWorkers(workerStats, queue); + } catch (error) { + error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.method === undefined ? (error.method = "addJob") : null; + throw error; + } + } + + /** + * Deletes a repeatable job from its queue and adjusts worker scaling + * @async + * @param {Object} monitor - Monitor object containing job details + * @param {string} monitor._id - ID of the monitor/job to delete + * @param {string} monitor.type - Type of monitor determining queue selection + * @param {number} monitor.interval - Job repeat interval in milliseconds + * @throws {Error} If queue not found for monitor type + * @throws {Error} If job deletion fails + * @description + * 1. Identifies correct queue based on monitor type + * 2. Removes repeatable job using monitor ID and interval + * 3. Logs success or failure of deletion + * 4. Updates worker scaling based on new queue load + * Returns void but logs operation result + */ + async deleteJob(monitor) { + try { + const queue = this.queues[QUEUE_LOOKUP[monitor.type]]; + const schedulerId = getSchedulerId(monitor); + const wasDeleted = await queue.removeJobScheduler(schedulerId); + + if (wasDeleted === true) { + this.logger.info({ + message: this.stringService.jobQueueDeleteJob, + service: SERVICE_NAME, + method: "deleteJob", + details: `Deleted job ${monitor._id}`, + }); + const workerStats = await this.getWorkerStats(queue); + await this.scaleWorkers(workerStats, queue); + } else { + this.logger.error({ + message: this.stringService.jobQueueDeleteJob, + service: SERVICE_NAME, + method: "deleteJob", + details: `Failed to delete job ${monitor._id}`, + }); + } + } catch (error) { + error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.method === undefined ? (error.method = "deleteJob") : null; + throw error; + } + } + + /** + * Retrieves comprehensive metrics for all queues + * @async + * @returns {Promise>} Object with metrics for each queue + * @throws {Error} If metrics retrieval fails + * @description + * Collects the following metrics for each queue: + * - Number of waiting jobs + * - Number of active jobs + * - Number of completed jobs + * - Number of failed jobs + * - Number of delayed jobs + * - Number of repeatable jobs + * - Number of active workers + * + * @typedef {Object} QueueMetrics + * @property {number} waiting - Count of jobs waiting to be processed + * @property {number} active - Count of jobs currently being processed + * @property {number} completed - Count of successfully completed jobs + * @property {number} failed - Count of failed jobs + * @property {number} delayed - Count of delayed jobs + * @property {number} repeatableJobs - Count of repeatable job patterns + * @property {number} workers - Count of active workers for this queue + */ + async getMetrics() { + try { + let metrics = {}; + + await Promise.all( + QUEUE_NAMES.map(async (name) => { + const queue = this.queues[name]; + const workers = this.workers[name]; + const [waiting, active, completed, failed, delayed, repeatableJobs] = + await Promise.all([ + queue.getWaitingCount(), + queue.getActiveCount(), + queue.getCompletedCount(), + queue.getFailedCount(), + queue.getDelayedCount(), + queue.getRepeatableJobs(), + ]); + + metrics[name] = { + waiting, + active, + completed, + failed, + delayed, + repeatableJobs: repeatableJobs.length, + workers: workers.length, + }; + }) + ); + + return metrics; + } catch (error) { + this.logger.error({ + message: error.message, + service: SERVICE_NAME, + method: "getMetrics", + stack: error.stack, + }); + } + } + + /** + * @async + * @returns {Promise} - Returns true if obliteration is successful + */ + + async obliterate() { + try { + this.logger.info({ + message: "Attempting to obliterate job queue...", + service: SERVICE_NAME, + method: "obliterate", + }); + + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); + this.healthCheckInterval = null; + } + + await Promise.all( + QUEUE_NAMES.map(async (name) => { + const queue = this.queues[name]; + await queue.pause(); + const jobs = await this.getJobs(queue); + + // Remove all repeatable jobs + for (const job of jobs) { + await queue.removeRepeatableByKey(job.key); + await queue.remove(job.id); + } + }) + ); + + // Close workers + await Promise.all( + QUEUE_NAMES.map(async (name) => { + const workers = this.workers[name]; + await Promise.all( + workers.map(async (worker) => { + await worker.close(); + }) + ); + }) + ); + + QUEUE_NAMES.forEach(async (name) => { + const queue = this.queues[name]; + await queue.obliterate(); + }); + + const metrics = await this.getMetrics(); + this.logger.info({ + message: this.stringService.jobQueueObliterate, + service: SERVICE_NAME, + method: "obliterate", + details: metrics, + }); + return true; + } catch (error) { + error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.method === undefined ? (error.method = "obliterate") : null; + throw error; + } + } + + // ************************** + // Queue Health Checks + // ************************** + async getKeyValuePairs() { + try { + // Get all keys + const keys = await this.connection.keys("*"); + + if (keys.length === 0) { + return {}; // Return an empty object if no keys are found + } + + // Get values for all keys + const values = await this.connection.mget(keys); + + // Combine keys and values into an object + const keyValuePairs = keys.reduce((result, key, index) => { + result[key] = values[index]; + return result; + }, {}); + this.logger.info({ + message: "Redis key-value", + service: SERVICE_NAME, + method: "flushQueue", + details: keyValuePairs, + }); + return keyValuePairs; + } catch (error) { + error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.method === undefined ? (error.method = "getKeyValuePairs") : null; + throw error; + } + } + + async flushQueue() { + try { + const keyValuePairs = await this.getKeyValuePairs(); + this.logger.info({ + message: "Before flush", + service: SERVICE_NAME, + method: "flushQueue", + details: keyValuePairs, + }); + const flushResult = await this.connection.flushall(); + const keyValuePairsAfter = await this.getKeyValuePairs(); + this.logger.info({ + message: "After flush", + service: SERVICE_NAME, + method: "flushQueue", + details: keyValuePairsAfter, + }); + if (flushResult !== "OK") { + throw new Error("Failed to flush queue"); + } + await this.initJobQueue(); + return { + keyValuePairs, + flush: flushResult, + keyValuePairsAfter, + init: true, + }; + } catch (error) { + error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.method === undefined ? (error.method = "flushQueue") : null; + throw error; + } + } + + /** + * Gets metrics for a specific queue + * @async + * @function getQueueHealthMetrics + * @param {Queue} queue - The queue to get metrics for + * @returns {Promise} Queue metrics + */ + async getQueueHealthMetrics(queue) { + return await queue.getJobCounts(); + } + + getQueueIdleTimes() { + const now = Date.now(); + const idleTimes = {}; + Object.entries(this.lastJobProcessedTime).forEach(([queueName, lastProcessed]) => { + idleTimes[queueName] = now - lastProcessed; + }); + return idleTimes; + } + async checkQueueHealth() { + try { + const currentTime = Date.now(); + const stuckQueues = []; + const idleTimes = this.getQueueIdleTimes(); + for (const queueName of QUEUE_NAMES) { + const queue = this.queues[queueName]; + + const jobCounts = await this.getQueueHealthMetrics(queue); + const hasJobs = Object.values(jobCounts).some((count) => count > 0); + + const timeSinceLastProcessed = currentTime - this.lastJobProcessedTime[queueName]; + const isStuck = hasJobs && timeSinceLastProcessed > HEALTH_CHECK_INTERVAL; + + if (isStuck) { + stuckQueues.push(queueName); + } + } + + const queueHealth = { stuck: false, stuckQueues, idleTimes }; + + if (stuckQueues.length > 0) { + queueHealth.stuck = true; + } + + this.logger.info({ + message: "Queue health check", + service: SERVICE_NAME, + method: "checkQueueHealth", + details: queueHealth, + }); + return queueHealth; + } catch (error) { + error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.method === undefined ? (error.method = "checkQueueHealth") : null; + throw error; + } + } +} + +export default NewJobQueue; diff --git a/server/service/networkService.js b/server/service/networkService.js new file mode 100755 index 000000000..e39e0a93a --- /dev/null +++ b/server/service/networkService.js @@ -0,0 +1,519 @@ +import jmespath from "jmespath"; +const SERVICE_NAME = "NetworkService"; +const UPROCK_ENDPOINT = "https://api.uprock.com/checkmate/push"; + +/** + * Constructs a new NetworkService instance. + * + * @param {Object} axios - The axios instance for HTTP requests. + * @param {Object} ping - The ping utility for network checks. + * @param {Object} logger - The logger instance for logging. + * @param {Object} http - The HTTP utility for network operations. + * @param {Object} net - The net utility for network operations. + */ +class NetworkService { + static SERVICE_NAME = SERVICE_NAME; + + constructor(axios, ping, logger, http, Docker, net, stringService, settingsService) { + this.TYPE_PING = "ping"; + this.TYPE_HTTP = "http"; + this.TYPE_PAGESPEED = "pagespeed"; + this.TYPE_HARDWARE = "hardware"; + this.TYPE_DOCKER = "docker"; + this.TYPE_PORT = "port"; + this.TYPE_DISTRIBUTED_HTTP = "distributed_http"; + this.TYPE_DISTRIBUTED_TEST = "distributed_test"; + this.SERVICE_NAME = SERVICE_NAME; + this.NETWORK_ERROR = 5000; + this.PING_ERROR = 5001; + this.axios = axios; + this.ping = ping; + this.logger = logger; + this.http = http; + this.Docker = Docker; + this.net = net; + this.stringService = stringService; + this.settingsService = settingsService; + this.settings = settingsService.getSettings(); + } + + /** + * Times the execution of an asynchronous operation. + * + * @param {Function} operation - The asynchronous operation to be timed. + * @returns {Promise} An object containing the response, response time, and optionally an error. + * @property {Object|null} response - The response from the operation, or null if an error occurred. + * @property {number} responseTime - The time taken for the operation to complete, in milliseconds. + * @property {Error} [error] - The error object if an error occurred during the operation. + */ + async timeRequest(operation) { + const startTime = Date.now(); + try { + const response = await operation(); + const endTime = Date.now(); + const responseTime = endTime - startTime; + return { response, responseTime }; + } catch (error) { + const endTime = Date.now(); + const responseTime = endTime - startTime; + return { response: null, responseTime, error }; + } + } + + /** + * Sends a ping request to the specified URL and returns the response. + * + * @param {Object} job - The job object containing the data for the ping request. + * @param {Object} job.data - The data object within the job. + * @param {string} job.data.url - The URL to ping. + * @param {string} job.data._id - The monitor ID for the ping request. + * @returns {Promise} An object containing the ping response details. + * @property {string} monitorId - The monitor ID for the ping request. + * @property {string} type - The type of request, which is "ping". + * @property {number} responseTime - The time taken for the ping request to complete, in milliseconds. + * @property {Object} payload - The response payload from the ping request. + * @property {boolean} status - The status of the ping request (true if successful, false otherwise). + * @property {number} code - The response code (200 if successful, error code otherwise). + * @property {string} message - The message indicating the result of the ping request. + */ + async requestPing(job) { + try { + const url = job.data.url; + const { response, responseTime, error } = await this.timeRequest(() => + this.ping.promise.probe(url) + ); + + const pingResponse = { + monitorId: job.data._id, + type: "ping", + responseTime, + payload: response, + }; + if (error) { + pingResponse.status = false; + pingResponse.code = this.PING_ERROR; + pingResponse.message = "No response"; + return pingResponse; + } + + pingResponse.code = 200; + pingResponse.status = response.alive; + pingResponse.message = "Success"; + return pingResponse; + } catch (error) { + error.service = this.SERVICE_NAME; + error.method = "requestPing"; + throw error; + } + } + + /** + * Sends an HTTP GET request to the specified URL and returns the response. + * + * @param {Object} job - The job object containing the data for the HTTP request. + * @param {Object} job.data - The data object within the job. + * @param {string} job.data.url - The URL to send the HTTP GET request to. + * @param {string} job.data._id - The monitor ID for the HTTP request. + * @param {string} [job.data.secret] - Secret for authorization if provided. + * @returns {Promise} An object containing the HTTP response details. + * @property {string} monitorId - The monitor ID for the HTTP request. + * @property {string} type - The type of request, which is "http". + * @property {number} responseTime - The time taken for the HTTP request to complete, in milliseconds. + * @property {Object} payload - The response payload from the HTTP request. + * @property {boolean} status - The status of the HTTP request (true if successful, false otherwise). + * @property {number} code - The response code (200 if successful, error code otherwise). + * @property {string} message - The message indicating the result of the HTTP request. + */ + async requestHttp(job) { + try { + const { + url, + secret, + _id, + name, + teamId, + type, + jsonPath, + matchMethod, + expectedValue, + } = job.data; + const config = {}; + + secret !== undefined && (config.headers = { Authorization: `Bearer ${secret}` }); + + const { response, responseTime, error } = await this.timeRequest(() => + this.axios.get(url, config) + ); + + const httpResponse = { + monitorId: _id, + teamId, + type, + responseTime, + payload: response?.data, + }; + + if (error) { + const code = error.response?.status || this.NETWORK_ERROR; + httpResponse.code = code; + httpResponse.status = false; + httpResponse.message = + this.http.STATUS_CODES[code] || this.stringService.httpNetworkError; + return httpResponse; + } + + httpResponse.code = response.status; + + if (!expectedValue) { + // not configure expected value, return + httpResponse.status = true; + httpResponse.message = this.http.STATUS_CODES[response.status]; + return httpResponse; + } + + // validate if response data match expected value + let result = response?.data; + + this.logger.info({ + service: this.SERVICE_NAME, + method: "requestHttp", + message: `Job: [${name}](${_id}) match result with expected value`, + details: { expectedValue, result, jsonPath, matchMethod }, + }); + + if (jsonPath) { + const contentType = response.headers["content-type"]; + + const isJson = contentType?.includes("application/json"); + if (!isJson) { + httpResponse.status = false; + httpResponse.message = this.stringService.httpNotJson; + return httpResponse; + } + + try { + result = jmespath.search(result, jsonPath); + } catch (error) { + httpResponse.status = false; + httpResponse.message = this.stringService.httpJsonPathError; + return httpResponse; + } + } + + if (result === null || result === undefined) { + httpResponse.status = false; + httpResponse.message = this.stringService.httpEmptyResult; + return httpResponse; + } + + let match; + result = typeof result === "object" ? JSON.stringify(result) : result.toString(); + if (matchMethod === "include") match = result.includes(expectedValue); + else if (matchMethod === "regex") match = new RegExp(expectedValue).test(result); + else match = result === expectedValue; + + httpResponse.status = match; + httpResponse.message = match + ? this.stringService.httpMatchSuccess + : this.stringService.httpMatchFail; + return httpResponse; + } catch (error) { + error.service = this.SERVICE_NAME; + error.method = "requestHttp"; + throw error; + } + } + + /** + * Sends a request to the Google PageSpeed Insights API for the specified URL and returns the response. + * + * @param {Object} job - The job object containing the data for the PageSpeed request. + * @param {Object} job.data - The data object within the job. + * @param {string} job.data.url - The URL to analyze with PageSpeed Insights. + * @param {string} job.data._id - The monitor ID for the PageSpeed request. + * @returns {Promise} An object containing the PageSpeed response details. + * @property {string} monitorId - The monitor ID for the PageSpeed request. + * @property {string} type - The type of request, which is "pagespeed". + * @property {number} responseTime - The time taken for the PageSpeed request to complete, in milliseconds. + * @property {Object} payload - The response payload from the PageSpeed request. + * @property {boolean} status - The status of the PageSpeed request (true if successful, false otherwise). + * @property {number} code - The response code (200 if successful, error code otherwise). + * @property {string} message - The message indicating the result of the PageSpeed request. + */ + async requestPagespeed(job) { + try { + const url = job.data.url; + const updatedJob = { ...job }; + let pagespeedUrl = `https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&category=seo&category=accessibility&category=best-practices&category=performance`; + if (this.settings?.pagespeedApiKey) { + pagespeedUrl += `&key=${this.settings.pagespeedApiKey}`; + } + updatedJob.data.url = pagespeedUrl; + return await this.requestHttp(updatedJob); + } catch (error) { + error.service = this.SERVICE_NAME; + error.method = "requestPagespeed"; + throw error; + } + } + + /** + * Sends an HTTP request to check hardware status and returns the response. + * + * @param {Object} job - The job object containing the data for the hardware request. + * @param {Object} job.data - The data object within the job. + * @param {string} job.data.url - The URL to send the hardware status request to. + * @param {string} job.data._id - The monitor ID for the hardware request. + * @param {string} job.data.type - The type of request, which is "hardware". + * @returns {Promise} An object containing the hardware status response details. + * @property {string} monitorId - The monitor ID for the hardware request. + * @property {string} type - The type of request ("hardware"). + * @property {number} responseTime - The time taken for the request to complete, in milliseconds. + * @property {Object} payload - The response payload from the hardware status request. + * @property {boolean} status - The status of the request (true if successful, false otherwise). + * @property {number} code - The response code (200 if successful, error code otherwise). + * @property {string} message - The message indicating the result of the hardware status request. + */ + async requestHardware(job) { + try { + return await this.requestHttp(job); + } catch (error) { + error.service = this.SERVICE_NAME; + error.method = "requestHardware"; + throw error; + } + } + + /** + * Sends a request to inspect a Docker container and returns its status. + * + * @param {Object} job - The job object containing the data for the Docker request. + * @param {Object} job.data - The data object within the job. + * @param {string} job.data.url - The container ID or name to inspect. + * @param {string} job.data._id - The monitor ID for the Docker request. + * @param {string} job.data.type - The type of request, which is "docker". + * @returns {Promise} An object containing the Docker container status details. + * @property {string} monitorId - The monitor ID for the Docker request. + * @property {string} type - The type of request ("docker"). + * @property {number} responseTime - The time taken for the Docker inspection to complete, in milliseconds. + * @property {boolean} status - The status of the container (true if running, false otherwise). + * @property {number} code - The response code (200 if successful, error code otherwise). + * @property {string} message - The message indicating the result of the Docker inspection. + */ + async requestDocker(job) { + try { + const docker = new this.Docker({ + socketPath: "/var/run/docker.sock", + handleError: true, // Enable error handling + }); + + const containers = await docker.listContainers({ all: true }); + const containerExists = containers.some((c) => c.Id.startsWith(job.data.url)); + if (!containerExists) { + throw new Error(this.stringService.dockerNotFound); + } + const container = docker.getContainer(job.data.url); + + const { response, responseTime, error } = await this.timeRequest(() => + container.inspect() + ); + + const dockerResponse = { + monitorId: job.data._id, + type: job.data.type, + responseTime, + }; + + if (error) { + dockerResponse.status = false; + dockerResponse.code = error.statusCode || this.NETWORK_ERROR; + dockerResponse.message = + error.reason || "Failed to fetch Docker container information"; + return dockerResponse; + } + dockerResponse.status = response?.State?.Status === "running" ? true : false; + dockerResponse.code = 200; + dockerResponse.message = "Docker container status fetched successfully"; + return dockerResponse; + } catch (error) { + error.service = this.SERVICE_NAME; + error.method = "requestDocker"; + throw error; + } + } + + async requestPort(job) { + try { + const { url, port } = job.data; + const { response, responseTime, error } = await this.timeRequest(async () => { + return new Promise((resolve, reject) => { + const socket = this.net.createConnection( + { + host: url, + port, + }, + () => { + socket.end(); + socket.destroy(); + resolve({ success: true }); + } + ); + + socket.setTimeout(5000); + socket.on("timeout", () => { + socket.destroy(); + reject(new Error("Connection timeout")); + }); + + socket.on("error", (err) => { + socket.destroy(); + reject(err); + }); + }); + }); + + const portResponse = { + monitorId: job.data._id, + type: job.data.type, + responseTime, + }; + + if (error) { + portResponse.status = false; + portResponse.code = this.NETWORK_ERROR; + portResponse.message = this.stringService.portFail; + return portResponse; + } + + portResponse.status = response.success; + portResponse.code = 200; + portResponse.message = this.stringService.portSuccess; + return portResponse; + } catch (error) { + error.service = this.SERVICE_NAME; + error.method = "requestTCP"; + throw error; + } + } + + async requestDistributedHttp(job) { + try { + const monitor = job.data; + const CALLBACK_URL = process.env.CALLBACK_URL; + + const response = await this.axios.post( + UPROCK_ENDPOINT, + { + id: monitor._id, + url: monitor.url, + callback: `${CALLBACK_URL}/api/v1/distributed-uptime/callback`, + }, + { + headers: { + "Content-Type": "application/json", + "x-checkmate-key": process.env.UPROCK_API_KEY, + }, + } + ); + if (response.data.success === false) { + throw new Error(response.data.message); + } + } catch (error) { + this.logger.error({ + message: "Error in requestDistributedHttp", + service: this.SERVICE_NAME, + method: "requestDistributedHttp", + stack: error.stack, + }); + error.service = this.SERVICE_NAME; + error.method = "requestDistributedHttp"; + throw error; + } + } + + /** + * Handles unsupported job types by throwing an error with details. + * + * @param {string} type - The unsupported job type that was provided + * @throws {Error} An error with service name, method name and unsupported type message + */ + handleUnsupportedType(type) { + const err = new Error(`Unsupported type: ${type}`); + err.service = this.SERVICE_NAME; + err.method = "getStatus"; + throw err; + } + + async requestWebhook(platform, url, message) { + try { + const response = await this.axios.post(url, message, { + headers: { + "Content-Type": "application/json", + }, + }); + + return { + type: "webhook", + status: true, + code: response.status, + message: `Successfully sent ${platform} notification`, + payload: response.data, + }; + } catch (error) { + this.logger.warn({ + message: error.message, + service: this.SERVICE_NAME, + method: "requestWebhook", + url, + platform, + error: error.message, + statusCode: error.response?.status, + responseData: error.response?.data, + requestPayload: message, + }); + + return { + type: "webhook", + status: false, + code: error.response?.status || this.NETWORK_ERROR, + message: `Failed to send ${platform} notification`, + payload: error.response?.data, + }; + } + } + + /** + * Gets the status of a job based on its type and returns the appropriate response. + * + * @param {Object} job - The job object containing the data for the status request. + * @param {Object} job.data - The data object within the job. + * @param {string} job.data.type - The type of the job (e.g., "ping", "http", "pagespeed", "hardware"). + * @returns {Promise} The response object from the appropriate request method. + * @throws {Error} Throws an error if the job type is unsupported. + */ + async getStatus(job) { + const type = job?.data?.type ?? "unknown"; + switch (type) { + case this.TYPE_PING: + return await this.requestPing(job); + case this.TYPE_HTTP: + return await this.requestHttp(job); + case this.TYPE_PAGESPEED: + return await this.requestPagespeed(job); + case this.TYPE_HARDWARE: + return await this.requestHardware(job); + case this.TYPE_DOCKER: + return await this.requestDocker(job); + case this.TYPE_PORT: + return await this.requestPort(job); + case this.TYPE_DISTRIBUTED_HTTP: + return await this.requestDistributedHttp(job); + case this.TYPE_DISTRIBUTED_TEST: + return; + default: + return this.handleUnsupportedType(type); + } + } +} + +export default NetworkService; diff --git a/server/service/notificationService.js b/server/service/notificationService.js new file mode 100755 index 000000000..624056694 --- /dev/null +++ b/server/service/notificationService.js @@ -0,0 +1,335 @@ +const SERVICE_NAME = "NotificationService"; +const TELEGRAM_API_BASE_URL = "https://api.telegram.org/bot"; +const PLATFORM_TYPES = ["telegram", "slack", "discord"]; + +const MESSAGE_FORMATTERS = { + telegram: (messageText, chatId) => ({ chat_id: chatId, text: messageText }), + slack: (messageText) => ({ text: messageText }), + discord: (messageText) => ({ content: messageText }), +}; + +class NotificationService { + static SERVICE_NAME = SERVICE_NAME; + /** + * Creates an instance of NotificationService. + * + * @param {Object} emailService - The email service used for sending notifications. + * @param {Object} db - The database instance for storing notification data. + * @param {Object} logger - The logger instance for logging activities. + * @param {Object} networkService - The network service for sending webhook notifications. + */ + constructor(emailService, db, logger, networkService, stringService) { + this.SERVICE_NAME = SERVICE_NAME; + this.emailService = emailService; + this.db = db; + this.logger = logger; + this.networkService = networkService; + this.stringService = stringService; + } + + /** + * Formats a notification message based on the monitor status and platform. + * + * @param {Object} monitor - The monitor object. + * @param {string} monitor.name - The name of the monitor. + * @param {string} monitor.url - The URL of the monitor. + * @param {boolean} status - The current status of the monitor (true for up, false for down). + * @param {string} platform - The notification platform (e.g., "telegram", "slack", "discord"). + * @param {string} [chatId] - The chat ID for platforms that require it (e.g., Telegram). + * @returns {Object|null} The formatted message object for the specified platform, or null if the platform is unsupported. + */ + + formatNotificationMessage(monitor, status, platform, chatId, code, timestamp) { + // Format timestamp using the local system timezone + const formatTime = (timestamp) => { + const date = new Date(timestamp); + + // Get timezone abbreviation and format the date + const timeZoneAbbr = date.toLocaleTimeString('en-US', { timeZoneName: 'short' }) + .split(' ').pop(); + + // Format the date with readable format + return date.toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }).replace(/(\d+)\/(\d+)\/(\d+),\s/, '$3-$1-$2 ') + ' ' + timeZoneAbbr; + }; + + // Get formatted time + const formattedTime = timestamp + ? formatTime(timestamp) + : formatTime(new Date().getTime()); + + // Create different messages based on status with extra spacing + let messageText; + if (status === true) { + messageText = this.stringService.monitorUpAlert + .replace("{monitorName}", monitor.name) + .replace("{time}", formattedTime) + .replace("{code}", code || 'Unknown'); + } else { + messageText = this.stringService.monitorDownAlert + .replace("{monitorName}", monitor.name) + .replace("{time}", formattedTime) + .replace("{code}", code || 'Unknown'); + } + + if (!PLATFORM_TYPES.includes(platform)) { + return undefined; + } + + return MESSAGE_FORMATTERS[platform](messageText, chatId); + } + + /** + * Sends a webhook notification to a specified platform. + * + * @param {Object} networkResponse - The response object from the network. + * @param {Object} networkResponse.monitor - The monitor object. + * @param {boolean} networkResponse.status - The monitor's status (true for up, false for down). + * @param {Object} notification - The notification settings. + * @param {string} notification.platform - The target platform ("telegram", "slack", "discord"). + * @param {Object} notification.config - The configuration object for the webhook. + * @param {string} notification.config.webhookUrl - The webhook URL for the platform. + * @param {string} [notification.config.botToken] - The bot token for Telegram notifications. + * @param {string} [notification.config.chatId] - The chat ID for Telegram notifications. + * @returns {Promise} A promise that resolves to true if the notification was sent successfully, otherwise false. + */ + + async sendWebhookNotification(networkResponse, notification) { + const { monitor, status, code } = networkResponse; + const { platform } = notification; + const { webhookUrl, botToken, chatId } = notification.config; + + // Early return if platform is not supported + if (!PLATFORM_TYPES.includes(platform)) { + this.logger.warn({ + message: this.stringService.getWebhookUnsupportedPlatform(platform), + service: this.SERVICE_NAME, + method: "sendWebhookNotification", + details: { platform } + }); + return false; + } + + // Early return for telegram if required fields are missing + if (platform === "telegram" && (!botToken || !chatId)) { + this.logger.warn({ + message: "Missing required fields for Telegram notification", + service: this.SERVICE_NAME, + method: "sendWebhookNotification", + details: { platform } + }); + return false; + } + + let url = webhookUrl; + if (platform === "telegram") { + url = `${TELEGRAM_API_BASE_URL}${botToken}/sendMessage`; + } + + const message = this.formatNotificationMessage( + monitor, + status, + platform, + chatId, + code, // Pass the code field directly + networkResponse.timestamp + ); + + try { + const response = await this.networkService.requestWebhook(platform, url, message); + return response.status; + } catch (error) { + this.logger.error({ + message: this.stringService.getWebhookSendError(platform), + service: this.SERVICE_NAME, + method: "sendWebhookNotification", + stack: error.stack, + }); + return false; + } + } + + /** + * Sends an email notification for hardware infrastructure alerts + * + * @async + * @function sendHardwareEmail + * @param {Object} networkResponse - Response object containing monitor information + * @param {string} address - Email address to send the notification to + * @param {Array} [alerts=[]] - List of hardware alerts to include in the email + * @returns {Promise} - Indicates whether email was sent successfully + * @throws {Error} + */ + async sendHardwareEmail(networkResponse, address, alerts = []) { + if (alerts.length === 0) return false; + const { monitor, status, prevStatus } = networkResponse; + const template = "hardwareIncidentTemplate"; + const context = { monitor: monitor.name, url: monitor.url, alerts }; + const subject = `Monitor ${monitor.name} infrastructure alerts`; + this.emailService.buildAndSendEmail(template, context, address, subject); + return true; + } + + /** + * Sends an email notification about monitor status change + * + * @async + * @function sendEmail + * @param {Object} networkResponse - Response object containing monitor status information + * @param {string} address - Email address to send the notification to + * @returns {Promise} - Indicates email was sent successfully + */ + async sendEmail(networkResponse, address) { + const { monitor, status, prevStatus } = networkResponse; + const template = prevStatus === false ? "serverIsUpTemplate" : "serverIsDownTemplate"; + const context = { monitor: monitor.name, url: monitor.url }; + const subject = `Monitor ${monitor.name} is ${status === true ? "up" : "down"}`; + this.emailService.buildAndSendEmail(template, context, address, subject); + return true; + } + + async handleStatusNotifications(networkResponse) { + try { + // If status hasn't changed, we're done + if (networkResponse.statusChanged === false) return false; + // if prevStatus is undefined, monitor is resuming, we're done + if (networkResponse.prevStatus === undefined) return false; + + const notifications = await this.db.getNotificationsByMonitorId( + networkResponse.monitorId + ); + + for (const notification of notifications) { + if (notification.type === "email") { + await this.sendEmail(networkResponse, notification.address); + } else if (notification.type === "webhook") { + await this.sendWebhookNotification(networkResponse, notification); + } + // Handle other types of notifications here + } + return true; + } catch (error) { + this.logger.error({ + message: error.message, + service: this.SERVICE_NAME, + method: "handleNotifications", + stack: error.stack, + }); + } + } + /** + * Handles status change notifications for a monitor + * + * @async + * @function handleStatusNotifications + * @param {Object} networkResponse - Response object containing monitor status information + * @returns {Promise} - Indicates whether notifications were processed + * @throws {Error} + */ + async handleHardwareNotifications(networkResponse) { + const thresholds = networkResponse?.monitor?.thresholds; + if (thresholds === undefined) return false; // No thresholds set, we're done + + // Get thresholds from monitor + const { + usage_cpu: cpuThreshold = -1, + usage_memory: memoryThreshold = -1, + usage_disk: diskThreshold = -1, + } = thresholds; + + // Get metrics from response + const metrics = networkResponse?.payload?.data ?? null; + if (metrics === null) return false; + + const { + cpu: { usage_percent: cpuUsage = -1 } = {}, + memory: { usage_percent: memoryUsage = -1 } = {}, + disk = [], + } = metrics; + + const alerts = { + cpu: cpuThreshold !== -1 && cpuUsage > cpuThreshold ? true : false, + memory: memoryThreshold !== -1 && memoryUsage > memoryThreshold ? true : false, + disk: disk.some((d) => diskThreshold !== -1 && d.usage_percent > diskThreshold) + ? true + : false, + }; + + const notifications = await this.db.getNotificationsByMonitorId( + networkResponse.monitorId + ); + for (const notification of notifications) { + const alertsToSend = []; + const alertTypes = ["cpu", "memory", "disk"]; + + for (const type of alertTypes) { + // Iterate over each alert type to see if any need to be decremented + if (alerts[type] === true) { + notification[`${type}AlertThreshold`]--; // Decrement threshold if an alert is triggered + + if (notification[`${type}AlertThreshold`] <= 0) { + // If threshold drops below 0, reset and send notification + notification[`${type}AlertThreshold`] = notification.alertThreshold; + + const formatAlert = { + cpu: () => + `Your current CPU usage (${(cpuUsage * 100).toFixed(0)}%) is above your threshold (${(cpuThreshold * 100).toFixed(0)}%)`, + memory: () => + `Your current memory usage (${(memoryUsage * 100).toFixed(0)}%) is above your threshold (${(memoryThreshold * 100).toFixed(0)}%)`, + disk: () => + `Your current disk usage: ${disk + .map((d, idx) => `(Disk${idx}: ${(d.usage_percent * 100).toFixed(0)}%)`) + .join( + ", " + )} is above your threshold (${(diskThreshold * 100).toFixed(0)}%)`, + }; + alertsToSend.push(formatAlert[type]()); + } + } + } + + await notification.save(); + + if (alertsToSend.length === 0) continue; // No alerts to send, we're done + + if (notification.type === "email") { + this.sendHardwareEmail(networkResponse, notification.address, alertsToSend); + } + } + return true; + } + + /** + * Handles notifications for different monitor types + * + * @async + * @function handleNotifications + * @param {Object} networkResponse - Response object containing monitor information + * @returns {Promise} - Indicates whether notifications were processed successfully + */ + async handleNotifications(networkResponse) { + try { + if (networkResponse.monitor.type === "hardware") { + this.handleHardwareNotifications(networkResponse); + } + this.handleStatusNotifications(networkResponse); + return true; + } catch (error) { + this.logger.error({ + message: error.message, + service: this.SERVICE_NAME, + method: "handleNotifications", + stack: error.stack, + }); + } + } +} + +export default NotificationService; diff --git a/server/service/serviceRegistry.js b/server/service/serviceRegistry.js new file mode 100755 index 000000000..69a3b8be5 --- /dev/null +++ b/server/service/serviceRegistry.js @@ -0,0 +1,35 @@ +const SERVICE_NAME = "ServiceRegistry"; +import logger from "../utils/logger.js"; +class ServiceRegistry { + static SERVICE_NAME = SERVICE_NAME; + constructor() { + this.services = {}; + } + + register(name, service) { + logger.info({ + message: `Registering service ${name}`, + service: SERVICE_NAME, + method: "register", + }); + this.services[name] = service; + } + + get(name) { + if (!this.services[name]) { + logger.error({ + message: `Service ${name} is not registered`, + service: SERVICE_NAME, + method: "get", + }); + throw new Error(`Service ${name} is not registered`); + } + return this.services[name]; + } + + listServices() { + return Object.keys(this.services); + } +} + +export default new ServiceRegistry(); diff --git a/server/service/settingsService.js b/server/service/settingsService.js new file mode 100755 index 000000000..6d3b8c66d --- /dev/null +++ b/server/service/settingsService.js @@ -0,0 +1,86 @@ +const SERVICE_NAME = "SettingsService"; +import dotenv from "dotenv"; +dotenv.config(); +const envConfig = { + logLevel: process.env.LOG_LEVEL, + apiBaseUrl: undefined, + language: process.env.LANGUAGE, + clientHost: process.env.CLIENT_HOST, + jwtSecret: process.env.JWT_SECRET, + dbType: process.env.DB_TYPE, + dbConnectionString: process.env.DB_CONNECTION_STRING, + redisUrl: process.env.REDIS_URL, + jwtTTL: process.env.TOKEN_TTL, + pagespeedApiKey: process.env.PAGESPEED_API_KEY, + systemEmailHost: process.env.SYSTEM_EMAIL_HOST, + systemEmailPort: process.env.SYSTEM_EMAIL_PORT, + systemEmailUser: process.env.SYSTEM_EMAIL_USER, + systemEmailAddress: process.env.SYSTEM_EMAIL_ADDRESS, + systemEmailPassword: process.env.SYSTEM_EMAIL_PASSWORD, +}; +/** + * SettingsService + * + * This service is responsible for loading and managing the application settings. + * It gives priority to environment variables and will only load settings + * from the database if they are not set in the environment. + */ +class SettingsService { + static SERVICE_NAME = SERVICE_NAME; + /** + * Constructs a new SettingsService + * @constructor + * @throws {Error} + */ constructor(appSettings) { + this.appSettings = appSettings; + this.settings = { ...envConfig }; + } + /** + * Load settings from the database and merge with environment settings. + * If there are any settings that weren't set by environment variables, use user settings from the database. + * @returns {Promise} The merged settings. + * @throws Will throw an error if settings are not found in the database or if settings have not been loaded. + */ async loadSettings() { + try { + const dbSettings = await this.appSettings.findOne(); + if (!this.settings) { + throw new Error("Settings not found"); + } + + // If there are any settings that weren't set by environment variables, use user settings from DB + for (const key in envConfig) { + if ( + typeof envConfig?.[key] === "undefined" && + typeof dbSettings?.[key] !== "undefined" + ) { + this.settings[key] = dbSettings[key]; + } + } + return this.settings; + } catch (error) { + error.service === undefined ? (error.service = SERVICE_NAME) : null; + error.method === undefined ? (error.method = "loadSettings") : null; + throw error; + } + } + /** + * Reload settings by calling loadSettings. + * @returns {Promise} The reloaded settings. + */ + async reloadSettings() { + return this.loadSettings(); + } + /** + * Get the current settings. + * @returns {Object} The current settings. + * @throws Will throw an error if settings have not been loaded. + */ + getSettings() { + if (!this.settings) { + throw new Error("Settings have not been loaded"); + } + return this.settings; + } +} + +export default SettingsService; diff --git a/server/service/statusService.js b/server/service/statusService.js new file mode 100755 index 000000000..79db6c96e --- /dev/null +++ b/server/service/statusService.js @@ -0,0 +1,302 @@ +import MonitorStats from "../db/models/MonitorStats.js"; +import { safelyParseFloat } from "../utils/dataUtils.js"; +const SERVICE_NAME = "StatusService"; + +class StatusService { + static SERVICE_NAME = SERVICE_NAME; + /** + * Creates an instance of StatusService. + * + * @param {Object} db - The database instance. + * @param {Object} logger - The logger instance. + */ + constructor({ db, logger, buffer }) { + this.db = db; + this.logger = logger; + this.buffer = buffer; + this.SERVICE_NAME = SERVICE_NAME; + } + + async updateRunningStats({ monitor, networkResponse }) { + try { + const monitorId = monitor._id; + const { responseTime, status, upt_burnt } = networkResponse; + // Get stats + let stats = await MonitorStats.findOne({ monitorId }); + if (!stats) { + stats = new MonitorStats({ + monitorId, + avgResponseTime: 0, + totalChecks: 0, + totalUpChecks: 0, + totalDownChecks: 0, + uptimePercentage: 0, + lastCheck: null, + timeSInceLastCheck: 0, + uptBurnt: 0, + }); + } + + // Update stats + + // Last response time + stats.lastResponseTime = responseTime; + + // Avg response time: + let avgResponseTime = stats.avgResponseTime; + if (typeof responseTime !== "undefined" && responseTime !== null) { + if (avgResponseTime === 0) { + avgResponseTime = responseTime; + } else { + avgResponseTime = + (avgResponseTime * (stats.totalChecks - 1) + responseTime) / + stats.totalChecks; + } + } + stats.avgResponseTime = avgResponseTime; + + // Total checks + stats.totalChecks++; + if (status === true) { + stats.totalUpChecks++; + // Update the timeSinceLastFailure if needed + if (stats.timeOfLastFailure === 0) { + stats.timeOfLastFailure = new Date().getTime(); + } + } else { + stats.totalDownChecks++; + stats.timeOfLastFailure = 0; + } + + // Calculate uptime percentage + let uptimePercentage; + if (stats.totalChecks > 0) { + uptimePercentage = stats.totalUpChecks / stats.totalChecks; + } else { + uptimePercentage = status === true ? 100 : 0; + } + stats.uptimePercentage = uptimePercentage; + + // latest check + stats.lastCheckTimestamp = new Date().getTime(); + + // UPT burned + if (typeof upt_burnt !== "undefined" && upt_burnt !== null) { + const currentUptBurnt = safelyParseFloat(stats.uptBurnt); + const newUptBurnt = safelyParseFloat(upt_burnt); + stats.uptBurnt = currentUptBurnt + newUptBurnt; + } + await stats.save(); + return true; + } catch (error) { + this.logger.error({ + service: this.SERVICE_NAME, + message: error.message, + method: "updateRunningStats", + stack: error.stack, + }); + return false; + } + } + + getStatusString = (status) => { + if (status === true) return "up"; + if (status === false) return "down"; + return "unknown"; + }; + /** + * Updates the status of a monitor based on the network response. + * + * @param {Object} networkResponse - The network response containing monitorId and status. + * @param {string} networkResponse.monitorId - The ID of the monitor. + * @param {string} networkResponse.status - The new status of the monitor. + * @returns {Promise} - A promise that resolves to an object containinfg the monitor, statusChanged flag, and previous status if the status changed, or false if an error occurred. + * @returns {Promise} returnObject - The object returned by the function. + * @returns {Object} returnObject.monitor - The monitor object. + * @returns {boolean} returnObject.statusChanged - Flag indicating if the status has changed. + * @returns {boolean} returnObject.prevStatus - The previous status of the monitor + */ + updateStatus = async (networkResponse) => { + this.insertCheck(networkResponse); + try { + const { monitorId, status, code } = networkResponse; + const monitor = await this.db.getMonitorById(monitorId); + + // Update running stats + this.updateRunningStats({ monitor, networkResponse }); + + // No change in monitor status, return early + if (monitor.status === status) + return { + monitor, + statusChanged: false, + prevStatus: monitor.status, + code, + timestamp: new Date().getTime(), + }; + + // Monitor status changed, save prev status and update monitor + this.logger.info({ + service: this.SERVICE_NAME, + message: `${monitor.name} went from ${this.getStatusString( + monitor.status + )} to ${this.getStatusString(status)}`, + prevStatus: monitor.status, + newStatus: status, + }); + + const prevStatus = monitor.status; + monitor.status = status; + await monitor.save(); + + return { + monitor, + statusChanged: true, + prevStatus, + code, + timestamp: new Date().getTime(), + }; + } catch (error) { + this.logger.error({ + service: this.SERVICE_NAME, + message: error.message, + method: "updateStatus", + stack: error.stack, + }); + throw error; + } + }; + + /** + * Builds a check object from the network response. + * + * @param {Object} networkResponse - The network response object. + * @param {string} networkResponse.monitorId - The monitor ID. + * @param {string} networkResponse.type - The type of the response. + * @param {string} networkResponse.status - The status of the response. + * @param {number} networkResponse.responseTime - The response time. + * @param {number} networkResponse.code - The status code. + * @param {string} networkResponse.message - The message. + * @param {Object} networkResponse.payload - The payload of the response. + * @returns {Object} The check object. + */ + buildCheck = (networkResponse) => { + const { + monitorId, + teamId, + type, + status, + responseTime, + code, + message, + payload, + first_byte_took, + body_read_took, + dns_took, + conn_took, + connect_took, + tls_took, + } = networkResponse; + + const check = { + monitorId, + teamId, + status, + statusCode: code, + responseTime, + message, + first_byte_took, + body_read_took, + dns_took, + conn_took, + connect_took, + tls_took, + }; + + if (type === "distributed_http") { + if (typeof payload === "undefined") { + return undefined; + } + check.continent = payload.continent; + check.countryCode = payload.country_code; + check.city = payload.city; + check.location = payload.location; + check.uptBurnt = payload.upt_burnt; + check.first_byte_took = payload.first_byte_took; + check.body_read_took = payload.body_read_took; + check.dns_took = payload.dns_took; + check.conn_took = payload.conn_took; + check.connect_took = payload.connect_took; + check.tls_took = payload.tls_took; + } + + if (type === "pagespeed") { + if (typeof payload === "undefined") { + return undefined; + } + const categories = payload?.lighthouseResult?.categories ?? {}; + const audits = payload?.lighthouseResult?.audits ?? {}; + const { + "cumulative-layout-shift": cls = 0, + "speed-index": si = 0, + "first-contentful-paint": fcp = 0, + "largest-contentful-paint": lcp = 0, + "total-blocking-time": tbt = 0, + } = audits; + check.accessibility = (categories?.accessibility?.score || 0) * 100; + check.bestPractices = (categories?.["best-practices"]?.score || 0) * 100; + check.seo = (categories?.seo?.score || 0) * 100; + check.performance = (categories?.performance?.score || 0) * 100; + check.audits = { cls, si, fcp, lcp, tbt }; + } + + if (type === "hardware") { + const { cpu, memory, disk, host } = payload?.data ?? {}; + const { errors } = payload?.errors ?? []; + check.cpu = cpu ?? {}; + check.memory = memory ?? {}; + check.disk = disk ?? {}; + check.host = host ?? {}; + check.errors = errors ?? []; + } + return check; + }; + + /** + * Inserts a check into the database based on the network response. + * + * @param {Object} networkResponse - The network response object. + * @param {string} networkResponse.monitorId - The monitor ID. + * @param {string} networkResponse.type - The type of the response. + * @param {string} networkResponse.status - The status of the response. + * @param {number} networkResponse.responseTime - The response time. + * @param {number} networkResponse.code - The status code. + * @param {string} networkResponse.message - The message. + * @param {Object} networkResponse.payload - The payload of the response. + * @returns {Promise} A promise that resolves when the check is inserted. + */ + insertCheck = async (networkResponse) => { + try { + const check = this.buildCheck(networkResponse); + if (typeof check === "undefined") { + this.logger.warn({ + message: "Failed to build check", + service: this.SERVICE_NAME, + method: "insertCheck", + details: networkResponse, + }); + return; + } + this.buffer.addToBuffer({ check, type: networkResponse.type }); + } catch (error) { + this.logger.error({ + message: error.message, + service: this.SERVICE_NAME, + method: "insertCheck", + details: `Error inserting check for monitor: ${networkResponse?.monitorId}`, + stack: error.stack, + }); + } + }; +} +export default StatusService; diff --git a/server/service/stringService.js b/server/service/stringService.js new file mode 100755 index 000000000..de900b23a --- /dev/null +++ b/server/service/stringService.js @@ -0,0 +1,425 @@ +class StringService { + static SERVICE_NAME = "StringService"; + + constructor(translationService) { + if (StringService.instance) { + return StringService.instance; + } + + this.translationService = translationService; + this._language = "en"; // default language + StringService.instance = this; + } + + setLanguage(language) { + this._language = language; + } + + get language() { + return this._language; + } + + // Auth Messages + get dontHaveAccount() { + return this.translationService.getTranslation("dontHaveAccount"); + } + + get email() { + return this.translationService.getTranslation("email"); + } + + get forgotPassword() { + return this.translationService.getTranslation("forgotPassword"); + } + + get password() { + return this.translationService.getTranslation("password"); + } + + get signUp() { + return this.translationService.getTranslation("signUp"); + } + + get submit() { + return this.translationService.getTranslation("submit"); + } + + get title() { + return this.translationService.getTranslation("title"); + } + + get continue() { + return this.translationService.getTranslation("continue"); + } + + get enterEmail() { + return this.translationService.getTranslation("enterEmail"); + } + + get authLoginTitle() { + return this.translationService.getTranslation("authLoginTitle"); + } + + get authLoginEnterPassword() { + return this.translationService.getTranslation("authLoginEnterPassword"); + } + + get commonPassword() { + return this.translationService.getTranslation("commonPassword"); + } + + get commonBack() { + return this.translationService.getTranslation("commonBack"); + } + + get authForgotPasswordTitle() { + return this.translationService.getTranslation("authForgotPasswordTitle"); + } + + get authForgotPasswordResetPassword() { + return this.translationService.getTranslation("authForgotPasswordResetPassword"); + } + + get createPassword() { + return this.translationService.getTranslation("createPassword"); + } + + get createAPassword() { + return this.translationService.getTranslation("createAPassword"); + } + + get authRegisterAlreadyHaveAccount() { + return this.translationService.getTranslation("authRegisterAlreadyHaveAccount"); + } + + get commonAppName() { + return this.translationService.getTranslation("commonAppName"); + } + + get authLoginEnterEmail() { + return this.translationService.getTranslation("authLoginEnterEmail"); + } + + get authRegisterTitle() { + return this.translationService.getTranslation("authRegisterTitle"); + } + + get monitorGetAll() { + return this.translationService.getTranslation("monitorGetAll"); + } + + get monitorGetById() { + return this.translationService.getTranslation("monitorGetById"); + } + + get monitorGetByIdSuccess() { + return this.translationService.getTranslation("monitorGetByIdSuccess"); + } + + get monitorCreate() { + return this.translationService.getTranslation("monitorCreate"); + } + + get bulkMonitorsCreate() { + return this.translationService.getTranslation("bulkMonitorsCreate"); + } + + get monitorEdit() { + return this.translationService.getTranslation("monitorEdit"); + } + + get monitorDelete() { + return this.translationService.getTranslation("monitorDelete"); + } + + get monitorPause() { + return this.translationService.getTranslation("monitorPause"); + } + + get monitorResume() { + return this.translationService.getTranslation("monitorResume"); + } + + get monitorDemoAdded() { + return this.translationService.getTranslation("monitorDemoAdded"); + } + + get monitorStatsById() { + return this.translationService.getTranslation("monitorStatsById"); + } + + get monitorCertificate() { + return this.translationService.getTranslation("monitorCertificate"); + } + + // Maintenance Window Messages + get maintenanceWindowCreate() { + return this.translationService.getTranslation("maintenanceWindowCreate"); + } + + get maintenanceWindowGetById() { + return this.translationService.getTranslation("maintenanceWindowGetById"); + } + + get maintenanceWindowGetByTeam() { + return this.translationService.getTranslation("maintenanceWindowGetByTeam"); + } + + get maintenanceWindowDelete() { + return this.translationService.getTranslation("maintenanceWindowDelete"); + } + + get maintenanceWindowEdit() { + return this.translationService.getTranslation("maintenanceWindowEdit"); + } + + // Webhook Messages + get webhookUnsupportedPlatform() { + return this.translationService.getTranslation("webhookUnsupportedPlatform"); + } + + get webhookSendError() { + return this.translationService.getTranslation("webhookSendError"); + } + + get webhookSendSuccess() { + return this.translationService.getTranslation("webhookSendSuccess"); + } + + get telegramRequiresBotTokenAndChatId() { + return this.translationService.getTranslation("telegramRequiresBotTokenAndChatId"); + } + + get webhookUrlRequired() { + return this.translationService.getTranslation("webhookUrlRequired"); + } + + get platformRequired() { + return this.translationService.getTranslation("platformRequired"); + } + + get testNotificationFailed() { + return this.translationService.getTranslation("testNotificationFailed"); + } + + get monitorUpAlert() { + return this.translationService.getTranslation("monitorUpAlert"); + } + + get monitorDownAlert() { + return this.translationService.getTranslation("monitorDownAlert"); + } + + getWebhookUnsupportedPlatform(platform) { + return this.translationService + .getTranslation("webhookUnsupportedPlatform") + .replace("{platform}", platform); + } + + getWebhookSendError(platform) { + return this.translationService + .getTranslation("webhookSendError") + .replace("{platform}", platform); + } + + getMonitorStatus(name, status, url) { + const translationKey = status === true ? "monitorStatusUp" : "monitorStatusDown"; + return this.translationService + .getTranslation(translationKey) + .replace("{name}", name) + .replace("{url}", url); + } + + // Error Messages + get unknownError() { + return this.translationService.getTranslation("unknownError"); + } + + get friendlyError() { + return this.translationService.getTranslation("friendlyError"); + } + + get authIncorrectPassword() { + return this.translationService.getTranslation("authIncorrectPassword"); + } + + get unauthorized() { + return this.translationService.getTranslation("unauthorized"); + } + + get authAdminExists() { + return this.translationService.getTranslation("authAdminExists"); + } + + get authInviteNotFound() { + return this.translationService.getTranslation("authInviteNotFound"); + } + + get unknownService() { + return this.translationService.getTranslation("unknownService"); + } + + get noAuthToken() { + return this.translationService.getTranslation("noAuthToken"); + } + + get invalidAuthToken() { + return this.translationService.getTranslation("invalidAuthToken"); + } + + get expiredAuthToken() { + return this.translationService.getTranslation("expiredAuthToken"); + } + + // Queue Messages + get queueGetMetrics() { + return this.translationService.getTranslation("queueGetMetrics"); + } + + get queueAddJob() { + return this.translationService.getTranslation("queueAddJob"); + } + + get queueObliterate() { + return this.translationService.getTranslation("queueObliterate"); + } + + // Job Queue Messages + get jobQueueDeleteJobSuccess() { + return this.translationService.getTranslation("jobQueueDeleteJobSuccess"); + } + + get jobQueuePauseJob() { + return this.translationService.getTranslation("jobQueuePauseJob"); + } + + get jobQueueResumeJob() { + return this.translationService.getTranslation("jobQueueResumeJob"); + } + + // Status Page Messages + get statusPageByUrl() { + return this.translationService.getTranslation("statusPageByUrl"); + } + + get statusPageCreate() { + return this.translationService.getTranslation("statusPageCreate"); + } + + get statusPageDelete() { + return this.translationService.getTranslation("statusPageDelete"); + } + + get statusPageUpdate() { + return this.translationService.getTranslation("statusPageUpdate"); + } + + get statusPageNotFound() { + return this.translationService.getTranslation("statusPageNotFound"); + } + + get statusPageByTeamId() { + return this.translationService.getTranslation("statusPageByTeamId"); + } + + get statusPageUrlNotUnique() { + return this.translationService.getTranslation("statusPageUrlNotUnique"); + } + + // Docker Messages + get dockerFail() { + return this.translationService.getTranslation("dockerFail"); + } + + get dockerNotFound() { + return this.translationService.getTranslation("dockerNotFound"); + } + + get dockerSuccess() { + return this.translationService.getTranslation("dockerSuccess"); + } + + // Port Messages + get portFail() { + return this.translationService.getTranslation("portFail"); + } + + get portSuccess() { + return this.translationService.getTranslation("portSuccess"); + } + + // Alert Messages + get alertCreate() { + return this.translationService.getTranslation("alertCreate"); + } + + get alertGetByUser() { + return this.translationService.getTranslation("alertGetByUser"); + } + + get alertGetByMonitor() { + return this.translationService.getTranslation("alertGetByMonitor"); + } + + get alertGetById() { + return this.translationService.getTranslation("alertGetById"); + } + + get alertEdit() { + return this.translationService.getTranslation("alertEdit"); + } + + get alertDelete() { + return this.translationService.getTranslation("alertDelete"); + } + + getDeletedCount(count) { + return this.translationService + .getTranslation("deletedCount") + .replace("{count}", count); + } + + get pingSuccess() { + return this.translationService.getTranslation("pingSuccess"); + } + + get getAppSettings() { + return this.translationService.getTranslation("getAppSettings"); + } + + get httpNetworkError() { + return this.translationService.getTranslation("httpNetworkError"); + } + + get httpNotJson() { + return this.translationService.getTranslation("httpNotJson"); + } + + get httpJsonPathError() { + return this.translationService.getTranslation("httpJsonPathError"); + } + + get httpEmptyResult() { + return this.translationService.getTranslation("httpEmptyResult"); + } + + get httpMatchSuccess() { + return this.translationService.getTranslation("httpMatchSuccess"); + } + + get httpMatchFail() { + return this.translationService.getTranslation("httpMatchFail"); + } + + get updateAppSettings() { + return this.translationService.getTranslation("updateAppSettings"); + } + + getDbFindMonitorById(monitorId) { + return this.translationService + .getTranslation("dbFindMonitorById") + .replace("${monitorId}", monitorId); + } +} + +export default StringService; diff --git a/server/service/translationService.js b/server/service/translationService.js new file mode 100755 index 000000000..de29b5eb1 --- /dev/null +++ b/server/service/translationService.js @@ -0,0 +1,91 @@ +import fs from "fs"; +import path from "path"; + +class TranslationService { + static SERVICE_NAME = "TranslationService"; + + constructor(logger) { + this.logger = logger; + this.translations = {}; + this._language = "en"; + this.localesDir = path.join(process.cwd(), "locales"); + } + + setLanguage(language) { + this._language = language; + } + + get language() { + return this._language; + } + + async initialize() { + try { + await this.loadFromFiles(); + } catch (error) { + this.logger.error({ + message: error.message, + service: "TranslationService", + method: "initialize", + stack: error.stack, + }); + } + } + + async loadFromFiles() { + try { + if (!fs.existsSync(this.localesDir)) { + return false; + } + + const files = fs + .readdirSync(this.localesDir) + .filter((file) => file.endsWith(".json")); + + if (files.length === 0) { + return false; + } + + for (const file of files) { + const language = file.replace(".json", ""); + const filePath = path.join(this.localesDir, file); + const content = fs.readFileSync(filePath, "utf8"); + this.translations[language] = JSON.parse(content); + } + + this.logger.info({ + message: "Translations loaded from files successfully", + service: "TranslationService", + method: "loadFromFiles", + }); + + return true; + } catch (error) { + this.logger.error({ + message: error.message, + service: "TranslationService", + method: "loadFromFiles", + stack: error.stack, + }); + return false; + } + } + + getTranslation(key) { + let language = this._language; + + try { + return this.translations[language]?.[key] || this.translations["en"]?.[key] || key; + } catch (error) { + this.logger.error({ + message: error.message, + service: "TranslationService", + method: "getTranslation", + stack: error.stack, + }); + return key; + } + } +} + +export default TranslationService; diff --git a/server/templates/addReview.mjml b/server/templates/addReview.mjml new file mode 100755 index 000000000..9d00be7d0 --- /dev/null +++ b/server/templates/addReview.mjml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + Message from Checkmate Service + + + + + + +

Hello {{name}}!

+

+ We hope you’re finding Checkmate helpful in monitoring your infrastructure. + Your support means a lot to us, and we truly appreciate having you as + part of our community. +

+

+ If you’re happy with Checkmate, we’d love to hear about your experience! + Leaving a review on G2 helps others discover Checkmate and supports our + ongoing improvements. +

+ G2 Link: TBD +

+ Thank you for taking the time to share your thoughts - we greatly appreciate + it! +

+ Checkmate Team +
+
+ + + +

This email was sent by Checkmate.

+
+
+
+
+
diff --git a/server/templates/employeeActivation.mjml b/server/templates/employeeActivation.mjml new file mode 100755 index 000000000..3e35db6e8 --- /dev/null +++ b/server/templates/employeeActivation.mjml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + Message from Checkmate Service + + + + + +

Hello {{name}}!

+

One of the admins created an account for you on the Checkmate server.

+

You can go ahead and create your account using this link.

+

{{link}}

+

Thank you.

+
+
+ + + +

This email was sent by Checkmate.

+
+
+
+
+
diff --git a/server/templates/hardwareIncident.mjml b/server/templates/hardwareIncident.mjml new file mode 100755 index 000000000..19fb589fc --- /dev/null +++ b/server/templates/hardwareIncident.mjml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + Message from BlueWave Infrastructure Monitoring + + + + + Infrastructure Alerts + + + + + + + +

Hello {{name}}!

+

{{monitor}} at {{url}} has the following infrastructure alerts:

+ {{#each alerts}} +

• {{this}}

+ {{/each}} +
+
+ + + View Infrastructure Details + +

This email was sent by BlueWave Infrastructure Monitoring.

+
+
+
+
+
diff --git a/server/templates/noIncidentsThisWeek.mjml b/server/templates/noIncidentsThisWeek.mjml new file mode 100755 index 000000000..d31d63d10 --- /dev/null +++ b/server/templates/noIncidentsThisWeek.mjml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + Message from Checkmate Service + + + No incidents this week! + + + + + + +

Hello {{name}}!

+

There were no incidents this week. Good job!

+

Current monitors:

+

Google: 100% availability

+

Canada.ca:100% availability

+
+
+ + + +

This email was sent by Checkmate.

+
+
+
+
+
diff --git a/server/templates/passwordReset.mjml b/server/templates/passwordReset.mjml new file mode 100755 index 000000000..e0b85a4f4 --- /dev/null +++ b/server/templates/passwordReset.mjml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + Message from Checkmate Service + + + + + + +

Hello {{name}}!

+

+ You are receiving this email because a password reset request has been made + for {{email}}. Please use the link below on the site to reset your password. +

+ Reset Password +

If you didn't request this, please ignore this email.

+ +

Thank you.

+
+
+ + + +

This email was sent by Checkmate.

+
+
+
+
+
diff --git a/server/templates/serverIsDown.mjml b/server/templates/serverIsDown.mjml new file mode 100755 index 000000000..b75d970e6 --- /dev/null +++ b/server/templates/serverIsDown.mjml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + Message from Checkmate Service + + + + + {{monitor}} is down + + + + + + + +

Hello {{name}}!

+

+ We detected an incident on one of your monitors. Your service is currently + down. We'll send a message to you once it is up again. +

+

Monitor name: {{monitor}}

+

URL: {{url}}

+
+
+ + + View incident details + +

This email was sent by Checkmate.

+
+
+
+
+
diff --git a/server/templates/serverIsUp.mjml b/server/templates/serverIsUp.mjml new file mode 100755 index 000000000..beb34481d --- /dev/null +++ b/server/templates/serverIsUp.mjml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + Message from Checkmate Service + + + + + {{monitor}} is up + + + + + + + +

Hello {{name}}!

+

Your latest incident is resolved and your monitored service is up again.

+

Monitor name: {{monitor}}

+

URL: {{url}}

+
+
+ + + View incident details + +

This email was sent by Checkmate.

+
+
+
+
+
diff --git a/server/templates/welcomeEmail.mjml b/server/templates/welcomeEmail.mjml new file mode 100755 index 000000000..663518ec2 --- /dev/null +++ b/server/templates/welcomeEmail.mjml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + Message from Checkmate Service + + + + + + +

Hello {{name}}!

+

+ Thank you for trying out Checkmate! We developed it with great care to meet + our own needs, and we're excited to share it with you. +

+

+ Checkmate is an automated way of checking whether a service such as a website + or an application is available or not. +

+

We hope you find our service as valuable as we do.

+

Thank you.

+
+
+ + + +

This email was sent by Checkmate.

+
+
+
+
+
diff --git a/server/tests/controllers/authController.test.js b/server/tests/controllers/authController.test.js new file mode 100755 index 000000000..2990afe22 --- /dev/null +++ b/server/tests/controllers/authController.test.js @@ -0,0 +1,973 @@ +import { + issueToken, + registerUser, + loginUser, + editUser, + checkSuperadminExists, + requestRecovery, + validateRecovery, + resetPassword, + deleteUser, + getAllUsers, +} from "../../controllers/authController.js"; +import jwt from "jsonwebtoken"; +import { errorMessages, successMessages } from "../../utils/messages.js"; +import sinon from "sinon"; +import { tokenType } from "../../utils/utils.js"; +import logger from "../../utils/logger.js"; + +describe("Auth Controller - issueToken", function () { + let stub; + + afterEach(function () { + sinon.restore(); // Restore stubs after each test + }); + + it("should reject with an error if jwt.sign fails", function () { + const error = new Error("jwt.sign error"); + stub = sinon.stub(jwt, "sign").throws(error); + const payload = { id: "123" }; + const appSettings = { jwtSecret: "my_secret" }; + expect(() => issueToken(payload, tokenType.ACCESS_TOKEN, appSettings)).to.throw( + error + ); + }); + + it("should return a token if jwt.sign is successful and appSettings.jwtTTL is not defined", function () { + const payload = { id: "123" }; + const appSettings = { jwtSecret: "my_secret" }; + const expectedToken = "mockToken"; + + stub = sinon.stub(jwt, "sign").returns(expectedToken); + const token = issueToken(payload, tokenType.ACCESS_TOKEN, appSettings); + expect(token).to.equal(expectedToken); + }); + + it("should return a token if jwt.sign is successful and appSettings.jwtTTL is defined", function () { + const payload = { id: "123" }; + const appSettings = { jwtSecret: "my_secret", jwtTTL: "1s" }; + const expectedToken = "mockToken"; + + stub = sinon.stub(jwt, "sign").returns(expectedToken); + const token = issueToken(payload, tokenType.ACCESS_TOKEN, appSettings); + expect(token).to.equal(expectedToken); + }); + + it("should return a refresh token if jwt.sign is successful and appSettings.refreshTokenTTL is not defined", function () { + const payload = {}; + const appSettings = { refreshTokenSecret: "my_refresh_secret" }; + const expectedToken = "mockRefreshToken"; + + stub = sinon.stub(jwt, "sign").returns(expectedToken); + const token = issueToken(payload, tokenType.REFRESH_TOKEN, appSettings); + expect(token).to.equal(expectedToken); + }); + + it("should return a refresh token if jwt.sign is successful and appSettings.refreshTokenTTL is defined", function () { + const payload = {}; + const appSettings = { + refreshTokenSecret: "my_refresh_secret", + refreshTokenTTL: "7d", + }; + const expectedToken = "mockRefreshToken"; + + stub = sinon.stub(jwt, "sign").returns(expectedToken); + const token = issueToken(payload, tokenType.REFRESH_TOKEN, appSettings); + expect(token).to.equal(expectedToken); + }); +}); + +describe("Auth Controller - registerUser", function () { + let req, res, next; + + beforeEach(function () { + req = { + body: { + firstName: "firstname", + lastName: "lastname", + email: "test@test.com", + password: "Uptime1!", + role: ["admin"], + teamId: "123", + inviteToken: "invite", + }, + db: { + checkSuperadmin: sinon.stub(), + getInviteTokenAndDelete: sinon.stub(), + updateAppSettings: sinon.stub(), + insertUser: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub().resolves({ + jwtSecret: "my_secret", + refreshTokenSecret: "my_refresh_secret", + }), + }, + emailService: { + buildAndSendEmail: sinon.stub(), + }, + file: {}, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + sinon.stub(logger, "error"); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if body validation fails", async function () { + req.body = {}; + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if checkSuperadmin fails", async function () { + req.db.checkSuperadmin.throws(new Error("checkSuperadmin error")); + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("checkSuperadmin error"); + }); + + it("should reject with an error if getInviteTokenAndDelete fails", async function () { + req.db.checkSuperadmin.returns(true); + req.db.getInviteTokenAndDelete.throws(new Error("getInviteTokenAndDelete error")); + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getInviteTokenAndDelete error"); + }); + + it("should reject with an error if updateAppSettings fails", async function () { + req.db.checkSuperadmin.returns(false); + req.db.updateAppSettings.throws(new Error("updateAppSettings error")); + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("updateAppSettings error"); + }); + + it("should reject with an error if insertUser fails", async function () { + req.db.checkSuperadmin.resolves(false); + req.db.updateAppSettings.resolves(); + req.db.insertUser.rejects(new Error("insertUser error")); + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("insertUser error"); + }); + + it("should reject with an error if settingsService.getSettings fails", async function () { + req.db.checkSuperadmin.resolves(false); + req.db.updateAppSettings.resolves(); + req.db.insertUser.resolves({ _id: "123" }); + req.settingsService.getSettings.rejects( + new Error("settingsService.getSettings error") + ); + await registerUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("settingsService.getSettings error"); + }); + + it("should log an error if emailService.buildAndSendEmail fails", async function () { + req.db.checkSuperadmin.resolves(false); + req.db.updateAppSettings.resolves(); + req.db.insertUser.returns({ _id: "123" }); + req.settingsService.getSettings.returns({ + jwtSecret: "my_secret", + refreshTokenSecret: "my_secret", + }); + req.emailService.buildAndSendEmail.rejects(new Error("emailService error")); + await registerUser(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0].message).to.equal("emailService error"); + }); + + it("should return a success message and data if all operations are successful", async function () { + const user = { _id: "123" }; + req.db.checkSuperadmin.resolves(false); + req.db.updateAppSettings.resolves(); + req.db.insertUser.returns(user); + req.settingsService.getSettings.returns({ + jwtSecret: "my_secret", + refreshTokenSecret: "my_secret", + }); + req.emailService.buildAndSendEmail.resolves("message-id"); + await registerUser(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.AUTH_CREATE_USER, + data: { user, token: sinon.match.string, refreshToken: sinon.match.string }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should return a success message and data if all operations are successful and superAdmin true", async function () { + const user = { _id: "123" }; + req.db.checkSuperadmin.resolves(true); + req.db.updateAppSettings.resolves(); + req.db.insertUser.returns(user); + req.settingsService.getSettings.returns({ + jwtSecret: "my_secret", + refreshTokenSecret: "my_secret", + }); + req.emailService.buildAndSendEmail.resolves("message-id"); + await registerUser(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.AUTH_CREATE_USER, + data: { user, token: sinon.match.string, refreshToken: sinon.match.string }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); +}); + +describe("Auth Controller - loginUser", function () { + let req, res, next, user; + + beforeEach(function () { + req = { + body: { email: "test@example.com", password: "Password123!" }, + db: { + getUserByEmail: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub().resolves({ + jwtSecret: "my_secret", + refreshTokenSecret: "my_refresh_token", + }), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + user = { + _doc: { + email: "test@example.com", + }, + comparePassword: sinon.stub(), + }; + }); + + it("should reject with an error if validation fails", async function () { + req.body = {}; + await loginUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if getUserByEmail fails", async function () { + req.db.getUserByEmail.rejects(new Error("getUserByEmail error")); + await loginUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getUserByEmail error"); + }); + + it("should login user successfully", async function () { + req.db.getUserByEmail.resolves(user); + user.comparePassword.resolves(true); + await loginUser(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.AUTH_LOGIN_USER, + data: { + user: { + email: "test@example.com", + avatarImage: undefined, + }, + token: sinon.match.string, + refreshToken: sinon.match.string, + }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should reject a user with an incorrect password", async function () { + req.body = { + email: "test@test.com", + password: "Password123!", + }; + req.db.getUserByEmail.resolves(user); + user.comparePassword.resolves(false); + await loginUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal( + errorMessages.AUTH_INCORRECT_PASSWORD + ); + }); +}); + +describe("Auth Controller - refreshAuthToken", function () { + let req, res, next, issueTokenStub; + + beforeEach(function () { + req = { + headers: { + "x-refresh-token": "valid_refresh_token", + authorization: "Bearer old_auth_token", + }, + settingsService: { + getSettings: sinon.stub().resolves({ + jwtSecret: "my_secret", + refreshTokenSecret: "my_refresh_secret", + }), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + sinon.stub(jwt, "verify"); + + issueTokenStub = sinon.stub().returns("new_auth_token"); + sinon.replace({ issueToken }, "issueToken", issueTokenStub); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject if no refresh token is provided", async function () { + delete req.headers["x-refresh-token"]; + await refreshAuthToken(req, res, next); + + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal(errorMessages.NO_REFRESH_TOKEN); + expect(next.firstCall.args[0].status).to.equal(401); + }); + + it("should reject if the refresh token is invalid", async function () { + jwt.verify.yields(new Error("invalid token")); + await refreshAuthToken(req, res, next); + + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal(errorMessages.INVALID_REFRESH_TOKEN); + expect(next.firstCall.args[0].status).to.equal(401); + }); + + it("should reject if the refresh token is expired", async function () { + const error = new Error("Token expired"); + error.name = "TokenExpiredError"; + jwt.verify.yields(error); + await refreshAuthToken(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal(errorMessages.EXPIRED_REFRESH_TOKEN); + expect(next.firstCall.args[0].status).to.equal(401); + }); + + it("should reject if settingsService.getSettings fails", async function () { + req.settingsService.getSettings.rejects( + new Error("settingsService.getSettings error") + ); + await refreshAuthToken(req, res, next); + + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("settingsService.getSettings error"); + }); + + it("should generate a new auth token if the refresh token is valid", async function () { + const decodedPayload = { expiresIn: "60" }; + jwt.verify.callsFake(() => { + return decodedPayload; + }); + await refreshAuthToken(req, res, next); + + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.AUTH_TOKEN_REFRESHED, + data: { + user: decodedPayload, + token: sinon.match.string, + refreshToken: "valid_refresh_token", + }, + }) + ).to.be.true; + }); +}); + +describe("Auth Controller - editUser", function () { + let req, res, next, stub, user; + + beforeEach(function () { + req = { + params: { userId: "123" }, + body: { password: "Password1!", newPassword: "Password2!" }, + headers: { authorization: "Bearer token" }, + user: { _id: "123" }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "my_secret" }), + }, + db: { + getUserByEmail: sinon.stub(), + updateUser: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + stub = sinon.stub(jwt, "verify").returns({ email: "test@example.com" }); + }); + + afterEach(function () { + sinon.restore(); + stub.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + req.params = {}; + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if body validation fails", async function () { + req.body = { invalid: 1 }; + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if param.userId !== req.user._id", async function () { + req.params = { userId: "456" }; + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(401); + }); + + it("should reject with an error if !req.body.password and getUserByEmail fails", async function () { + req.db.getUserByEmail.rejects(new Error("getUserByEmail error")); + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getUserByEmail error"); + }); + + it("should reject with an error if user.comparePassword fails", async function () { + req.db.getUserByEmail.returns({ + comparePassword: sinon.stub().rejects(new Error("Bad Password Match")), + }); + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Bad Password Match"); + }); + + it("should reject with an error if user.comparePassword returns false", async function () { + req.db.getUserByEmail.returns({ + comparePassword: sinon.stub().returns(false), + }); + await editUser(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(401); + expect(next.firstCall.args[0].message).to.equal( + errorMessages.AUTH_INCORRECT_PASSWORD + ); + }); + + it("should edit a user if it receives a proper request", async function () { + const user = { + comparePassword: sinon.stub().resolves(true), + }; + req.db.getUserByEmail.resolves(user); + req.db.updateUser.resolves({ email: "test@example.com" }); + + await editUser(req, res, next); + + expect(req.db.getUserByEmail.calledOnce).to.be.true; + expect(req.db.updateUser.calledOnce).to.be.true; + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.AUTH_UPDATE_USER, + data: { email: "test@example.com" }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should edit a user if it receives a proper request and both password fields are undefined", async function () { + req.body.password = undefined; + req.body.newPassword = undefined; + req.db.getUserByEmail.resolves(user); + req.db.updateUser.resolves({ email: "test@example.com" }); + + await editUser(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.AUTH_UPDATE_USER, + data: { email: "test@example.com" }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should reject an edit request if password format is incorrect", async function () { + req.body = { password: "bad_password", newPassword: "bad_password" }; + const user = { + comparePassword: sinon.stub().resolves(true), + }; + req.db.getUserByEmail.resolves(user); + + await editUser(req, res, next); + + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); +}); + +describe("Auth Controller - checkSuperadminExists", function () { + let req, res, next; + + beforeEach(function () { + req = { + db: { + checkSuperadmin: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + it("should reject with an error if checkSuperadmin fails", async function () { + req.db.checkSuperadmin.rejects(new Error("checkSuperadmin error")); + await checkSuperadminExists(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("checkSuperadmin error"); + }); + + it("should return true if a superadmin exists", async function () { + req.db.checkSuperadmin.resolves(true); + await checkSuperadminExists(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.AUTH_SUPERADMIN_EXISTS, + data: true, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should return false if a superadmin does not exist", async function () { + req.db.checkSuperadmin.resolves(false); + await checkSuperadminExists(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.AUTH_SUPERADMIN_EXISTS, + data: false, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); +}); + +describe("Auth Controller - requestRecovery", function () { + let req, res, next; + + beforeEach(function () { + req = { + body: { email: "test@test.com" }, + db: { + getUserByEmail: sinon.stub(), + requestRecoveryToken: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub().returns({ clientHost: "http://localhost" }), + }, + emailService: { + buildAndSendEmail: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + it("should reject with an error if validation fails", async function () { + req.body = {}; + await requestRecovery(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if getUserByEmail fails", async function () { + req.db.getUserByEmail.rejects(new Error("getUserByEmail error")); + await requestRecovery(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getUserByEmail error"); + }); + + it("should throw an error if the user is not found", async function () { + req.db.getUserByEmail.resolves(null); + await requestRecovery(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + // expect(next.firstCall.args[0].message).to.equal( + // errorMessages.FRIENDLY_ERROR + // ); + }); + + it("should throw an error if the email is not provided", async function () { + req.body = {}; + await requestRecovery(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should return a success message if the email is provided", async function () { + const user = { firstName: "John" }; + const recoveryToken = { token: "recovery-token" }; + const msgId = "message-id"; + req.db.getUserByEmail.resolves(user); + req.db.requestRecoveryToken.resolves(recoveryToken); + req.emailService.buildAndSendEmail.resolves(msgId); + await requestRecovery(req, res, next); + expect(req.db.getUserByEmail.calledOnceWith("test@test.com")).to.be.true; + expect(req.db.requestRecoveryToken.calledOnceWith(req, res)).to.be.true; + expect( + req.emailService.buildAndSendEmail.calledOnceWith( + "passwordResetTemplate", + { + name: "John", + email: "test@test.com", + url: "http://localhost/set-new-password/recovery-token", + }, + "test@test.com", + "Checkmate Password Reset" + ) + ).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.AUTH_CREATE_RECOVERY_TOKEN, + data: msgId, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); +}); + +describe("Auth Controller - validateRecovery", function () { + let req, res, next; + + beforeEach(function () { + req = { + body: { recoveryToken: "recovery-token" }, + db: { + validateRecoveryToken: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + it("should reject with an error if validation fails", async function () { + req.body = {}; + await validateRecovery(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if validateRecoveryToken fails", async function () { + req.db.validateRecoveryToken.rejects(new Error("validateRecoveryToken error")); + await validateRecovery(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("validateRecoveryToken error"); + }); + + it("should return a success message if the token is valid", async function () { + req.db.validateRecoveryToken.resolves(); + await validateRecovery(req, res, next); + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.AUTH_VERIFY_RECOVERY_TOKEN, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); +}); + +describe("Auth Controller - resetPassword", function () { + let req, res, next, newPasswordValidation, handleValidationError, handleError; + + beforeEach(function () { + req = { + body: { + recoveryToken: "recovery-token", + password: "Password1!", + }, + db: { + resetPassword: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + newPasswordValidation = { + validateAsync: sinon.stub(), + }; + handleValidationError = sinon.stub(); + handleError = sinon.stub(); + }); + + it("should reject with an error if validation fails", async function () { + req.body = { password: "bad_password" }; + await resetPassword(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if resetPassword fails", async function () { + const error = new Error("resetPassword error"); + newPasswordValidation.validateAsync.resolves(); + req.db.resetPassword.rejects(error); + await resetPassword(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("resetPassword error"); + }); + + it("should reset password successfully", async function () { + const user = { _doc: {} }; + const appSettings = { jwtSecret: "my_secret" }; + const token = "token"; + + newPasswordValidation.validateAsync.resolves(); + req.db.resetPassword.resolves(user); + req.settingsService.getSettings.resolves(appSettings); + + await resetPassword(req, res, next); + + expect(req.db.resetPassword.calledOnceWith(req, res)).to.be.true; + expect(req.settingsService.getSettings.calledOnce).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.AUTH_RESET_PASSWORD, + data: { user: sinon.match.object, token: sinon.match.string }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); +}); + +describe("Auth Controller - deleteUser", function () { + let req, res, next, handleError; + + beforeEach(function () { + req = { + headers: { + authorization: "Bearer token", + }, + db: { + getUserByEmail: sinon.stub(), + getMonitorsByTeamId: sinon.stub(), + deleteJob: sinon.stub(), + deleteChecks: sinon.stub(), + deletePageSpeedChecksByMonitorId: sinon.stub(), + deleteNotificationsByMonitorId: sinon.stub(), + deleteTeam: sinon.stub(), + deleteAllOtherUsers: sinon.stub(), + deleteMonitorsByUserId: sinon.stub(), + deleteUser: sinon.stub(), + }, + jobQueue: { + deleteJob: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + + sinon.stub(jwt, "decode"); + + handleError = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should throw an error if user is not found", async function () { + jwt.decode.returns({ email: "test@example.com" }); + req.db.getUserByEmail.throws(new Error(errorMessages.DB_USER_NOT_FOUND)); + + await deleteUser(req, res, next); + + expect(req.db.getUserByEmail.calledOnceWith("test@example.com")).to.be.true; + expect(next.calledOnce).to.be.true; + expect(next.firstCall.args[0].message).to.equal(errorMessages.DB_USER_NOT_FOUND); + expect(res.status.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + }); + + it("should delete user and associated data if user is superadmin", async function () { + const user = { + _id: "user_id", + email: "test@example.com", + role: ["superadmin"], + teamId: "team_id", + }; + const monitors = [{ _id: "monitor_id" }]; + + jwt.decode.returns({ email: "test@example.com" }); + req.db.getUserByEmail.resolves(user); + req.db.getMonitorsByTeamId.resolves({ monitors }); + + await deleteUser(req, res, next); + + expect(req.db.getUserByEmail.calledOnceWith("test@example.com")).to.be.true; + expect( + req.db.getMonitorsByTeamId.calledOnceWith({ + params: { teamId: "team_id" }, + }) + ).to.be.true; + expect(req.jobQueue.deleteJob.calledOnceWith(monitors[0])).to.be.true; + expect(req.db.deleteChecks.calledOnceWith("monitor_id")).to.be.true; + expect(req.db.deletePageSpeedChecksByMonitorId.calledOnceWith("monitor_id")).to.be + .true; + expect(req.db.deleteNotificationsByMonitorId.calledOnceWith("monitor_id")).to.be.true; + expect(req.db.deleteTeam.calledOnceWith("team_id")).to.be.true; + expect(req.db.deleteAllOtherUsers.calledOnce).to.be.true; + expect(req.db.deleteMonitorsByUserId.calledOnceWith("user_id")).to.be.true; + expect(req.db.deleteUser.calledOnceWith("user_id")).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.AUTH_DELETE_USER, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should delete user if user is not superadmin", async function () { + const user = { + _id: "user_id", + email: "test@example.com", + role: ["user"], + teamId: "team_id", + }; + + jwt.decode.returns({ email: "test@example.com" }); + req.db.getUserByEmail.resolves(user); + + await deleteUser(req, res, next); + + expect(req.db.getUserByEmail.calledOnceWith("test@example.com")).to.be.true; + expect( + req.db.getMonitorsByTeamId.calledOnceWith({ + params: { teamId: "team_id" }, + }) + ).to.be.true; + expect(req.db.deleteUser.calledOnceWith("user_id")).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.AUTH_DELETE_USER, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should handle errors", async function () { + const error = new Error("Something went wrong"); + const SERVICE_NAME = "AuthController"; + jwt.decode.returns({ email: "test@example.com" }); + req.db.getUserByEmail.rejects(error); + await deleteUser(req, res, next); + expect(next.calledOnce).to.be.true; + expect(next.firstCall.args[0].message).to.equal("Something went wrong"); + expect(res.status.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + }); +}); + +describe("Auth Controller - getAllUsers", function () { + let req, res, next; + + beforeEach(function () { + req = { + db: { + getAllUsers: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); // Restore the original methods after each test + }); + + it("should return 200 and all users", async function () { + const allUsers = [{ id: 1, name: "John Doe" }]; + req.db.getAllUsers.resolves(allUsers); + + await getAllUsers(req, res, next); + + expect(req.db.getAllUsers.calledOnce).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: "Got all users", + data: allUsers, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should call next with error when an exception occurs", async function () { + const error = new Error("Something went wrong"); + req.db.getAllUsers.rejects(error); + await getAllUsers(req, res, next); + expect(req.db.getAllUsers.calledOnce).to.be.true; + expect(next.calledOnce).to.be.true; + expect(res.status.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + }); +}); diff --git a/server/tests/controllers/checkController.test.js b/server/tests/controllers/checkController.test.js new file mode 100755 index 000000000..322e6c8ca --- /dev/null +++ b/server/tests/controllers/checkController.test.js @@ -0,0 +1,379 @@ +import { + createCheck, + getChecks, + getTeamChecks, + deleteChecks, + deleteChecksByTeamId, + updateChecksTTL, +} from "../../controllers/checkController.js"; +import jwt from "jsonwebtoken"; +import { errorMessages, successMessages } from "../../utils/messages.js"; +import sinon from "sinon"; +describe("Check Controller - createCheck", function () { + let req, res, next, handleError; + + beforeEach(function () { + req = { + params: {}, + body: {}, + db: { + createCheck: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + handleError = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); // Restore the original methods after each test + }); + + it("should reject with a validation if params are invalid", async function () { + await createCheck(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with a validation error if body is invalid", async function () { + req.params = { + monitorId: "monitorId", + }; + await createCheck(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should call next with error if data retrieval fails", async function () { + req.params = { + monitorId: "monitorId", + }; + req.body = { + monitorId: "monitorId", + status: true, + responseTime: 100, + statusCode: 200, + message: "message", + }; + req.db.createCheck.rejects(new Error("error")); + await createCheck(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + }); + + it("should return a success message if check is created", async function () { + req.params = { + monitorId: "monitorId", + }; + req.db.createCheck.resolves({ id: "123" }); + req.body = { + monitorId: "monitorId", + status: true, + responseTime: 100, + statusCode: 200, + message: "message", + }; + await createCheck(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.CHECK_CREATE, + data: { id: "123" }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); +}); + +describe("Check Controller - getChecks", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: {}, + query: {}, + db: { + getChecks: sinon.stub(), + getChecksCount: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with a validation error if params are invalid", async function () { + await getChecks(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should return a success message if checks are found", async function () { + req.params = { + monitorId: "monitorId", + }; + req.db.getChecks.resolves([{ id: "123" }]); + req.db.getChecksCount.resolves(1); + await getChecks(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: successMessages.CHECK_GET, + data: { checksCount: 1, checks: [{ id: "123" }] }, + }) + ).to.be.true; + expect(next.notCalled).to.be.true; + }); + + it("should call next with error if data retrieval fails", async function () { + req.params = { + monitorId: "monitorId", + }; + req.db.getChecks.rejects(new Error("error")); + await getChecks(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + }); +}); + +describe("Check Controller - getTeamChecks", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: {}, + query: {}, + db: { + getTeamChecks: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with a validation error if params are invalid", async function () { + await getTeamChecks(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should return 200 and check data on successful validation and data retrieval", async function () { + req.params = { teamId: "1" }; + const checkData = [{ id: 1, name: "Check 1" }]; + req.db.getTeamChecks.resolves(checkData); + + await getTeamChecks(req, res, next); + expect(req.db.getTeamChecks.calledOnceWith(req)).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.CHECK_GET, + data: checkData, + }) + ).to.be.true; + }); + + it("should call next with error if data retrieval fails", async function () { + req.params = { teamId: "1" }; + req.db.getTeamChecks.rejects(new Error("Retrieval Error")); + await getTeamChecks(req, res, next); + expect(req.db.getTeamChecks.calledOnceWith(req)).to.be.true; + expect(next.firstCall.args[0]).to.be.an("error"); + expect(res.status.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + }); +}); + +describe("Check Controller - deleteChecks", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: {}, + db: { + deleteChecks: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + await deleteChecks(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should call next with error if data retrieval fails", async function () { + req.params = { monitorId: "1" }; + req.db.deleteChecks.rejects(new Error("Deletion Error")); + await deleteChecks(req, res, next); + expect(req.db.deleteChecks.calledOnceWith(req.params.monitorId)).to.be.true; + expect(next.firstCall.args[0]).to.be.an("error"); + expect(res.status.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + }); + + it("should delete checks successfully", async function () { + req.params = { monitorId: "123" }; + req.db.deleteChecks.resolves(1); + await deleteChecks(req, res, next); + expect(req.db.deleteChecks.calledOnceWith(req.params.monitorId)).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.CHECK_DELETE, + data: { deletedCount: 1 }, + }) + ).to.be.true; + }); +}); + +describe("Check Controller - deleteChecksByTeamId", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: {}, + db: { + deleteChecksByTeamId: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + await deleteChecksByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should call next with error if data retrieval fails", async function () { + req.params = { teamId: "1" }; + req.db.deleteChecksByTeamId.rejects(new Error("Deletion Error")); + await deleteChecksByTeamId(req, res, next); + expect(req.db.deleteChecksByTeamId.calledOnceWith(req.params.teamId)).to.be.true; + expect(next.firstCall.args[0]).to.be.an("error"); + expect(res.status.notCalled).to.be.true; + expect(res.json.notCalled).to.be.true; + }); + + it("should delete checks successfully", async function () { + req.params = { teamId: "123" }; + req.db.deleteChecksByTeamId.resolves(1); + await deleteChecksByTeamId(req, res, next); + expect(req.db.deleteChecksByTeamId.calledOnceWith(req.params.teamId)).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.CHECK_DELETE, + data: { deletedCount: 1 }, + }) + ).to.be.true; + }); +}); + +describe("Check Controller - updateCheckTTL", function () { + let stub, req, res, next; + + beforeEach(function () { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + + req = { + body: {}, + headers: { authorization: "Bearer token" }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "my_secret" }), + }, + db: { + updateChecksTTL: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + stub.restore(); + }); + + it("should reject if body validation fails", async function () { + await updateChecksTTL(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should throw a JwtError if verification fails", async function () { + stub.restore(); + req.body = { + ttl: 1, + }; + await updateChecksTTL(req, res, next); + expect(next.firstCall.args[0]).to.be.instanceOf(jwt.JsonWebTokenError); + }); + + it("should call next with error if data retrieval fails", async function () { + req.body = { + ttl: 1, + }; + req.db.updateChecksTTL.rejects(new Error("Update Error")); + await updateChecksTTL(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + }); + + it("should update TTL successfully", async function () { + req.body = { + ttl: 1, + }; + req.db.updateChecksTTL.resolves(); + await updateChecksTTL(req, res, next); + expect(req.db.updateChecksTTL.calledOnceWith("123", 1 * 86400)).to.be.true; + expect(res.status.calledOnceWith(200)).to.be.true; + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.CHECK_UPDATE_TTL, + }) + ).to.be.true; + }); +}); diff --git a/server/tests/controllers/controllerUtils.test.js b/server/tests/controllers/controllerUtils.test.js new file mode 100755 index 000000000..d594e932d --- /dev/null +++ b/server/tests/controllers/controllerUtils.test.js @@ -0,0 +1,161 @@ +import sinon from "sinon"; + +import { + handleValidationError, + handleError, + fetchMonitorCertificate, +} from "../../controllers/controllerUtils.js"; +import { expect } from "chai"; +import sslChecker from "ssl-checker"; +import { afterEach } from "node:test"; +import exp from "constants"; + +describe("controllerUtils - handleValidationError", function () { + it("should set status to 422", function () { + const error = {}; + const serviceName = "TestService"; + const result = handleValidationError(error, serviceName); + expect(result.status).to.equal(422); + }); + + it("should set service to the provided serviceName", function () { + const error = {}; + const serviceName = "TestService"; + const result = handleValidationError(error, serviceName); + expect(result.service).to.equal(serviceName); + }); + + it("should set message to error.details[0].message if present", function () { + const error = { + details: [{ message: "Detail message" }], + }; + const serviceName = "TestService"; + const result = handleValidationError(error, serviceName); + expect(result.message).to.equal("Detail message"); + }); + + it("should set message to error.message if error.details is not present", function () { + const error = { + message: "Error message", + }; + const serviceName = "TestService"; + const result = handleValidationError(error, serviceName); + expect(result.message).to.equal("Error message"); + }); + + it('should set message to "Validation Error" if neither error.details nor error.message is present', function () { + const error = {}; + const serviceName = "TestService"; + const result = handleValidationError(error, serviceName); + expect(result.message).to.equal("Validation Error"); + }); +}); + +describe("controllerUtils - handleError", function () { + it("should set stats to the provided status if error.code is undefined", function () { + const error = {}; + const serviceName = "TestService"; + const method = "testMethod"; + const status = 400; + const result = handleError(error, serviceName, method, status); + expect(result.status).to.equal(status); + }); + + it("should not overwrite error.code if it is already defined", function () { + const error = { status: 404 }; + const serviceName = "TestService"; + const method = "testMethod"; + const status = 400; + const result = handleError(error, serviceName, method, status); + expect(result.status).to.equal(404); + }); + + it("should set service to the provided serviceName if error.service is undefined", function () { + const error = {}; + const serviceName = "TestService"; + const method = "testMethod"; + const result = handleError(error, serviceName, method); + expect(result.service).to.equal(serviceName); + }); + + it("should not overwrite error.service if it is already defined", function () { + const error = { service: "ExistingService" }; + const serviceName = "TestService"; + const method = "testMethod"; + const result = handleError(error, serviceName, method); + expect(result.service).to.equal("ExistingService"); + }); + + it("should set method to the provided method if error.method is undefined", function () { + const error = {}; + const serviceName = "TestService"; + const method = "testMethod"; + const result = handleError(error, serviceName, method); + expect(result.method).to.equal(method); + }); + + it("should not overwrite error.method if it is already defined", function () { + const error = { method: "existingMethod" }; + const serviceName = "TestService"; + const method = "testMethod"; + const result = handleError(error, serviceName, method); + expect(result.method).to.equal("existingMethod"); + }); + + it("should set code to 500 if error.code is undefined and no code is provided", function () { + const error = {}; + const serviceName = "TestService"; + const method = "testMethod"; + const result = handleError(error, serviceName, method); + expect(result.status).to.equal(500); + }); +}); + +describe("controllerUtils - fetchMonitorCertificate", function () { + let sslChecker, monitor; + + beforeEach(function () { + monitor = { + url: "https://www.google.com", + }; + sslChecker = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if a URL does not parse", async function () { + monitor.url = "invalidurl"; + try { + await fetchMonitorCertificate(sslChecker, monitor); + } catch (error) { + expect(error).to.be.an("error"); + expect(error.message).to.equal("Invalid URL"); + } + }); + + it("should reject with an error if sslChecker throws an error", async function () { + sslChecker.rejects(new Error("Test error")); + try { + await fetchMonitorCertificate(sslChecker, monitor); + } catch (error) { + expect(error).to.be.an("error"); + expect(error.message).to.equal("Test error"); + } + }); + + it("should return a certificate if sslChecker resolves", async function () { + sslChecker.resolves({ validTo: "2022-01-01" }); + const result = await fetchMonitorCertificate(sslChecker, monitor); + expect(result).to.deep.equal({ validTo: "2022-01-01" }); + }); + + it("should throw an error if a ssl-checker returns null", async function () { + sslChecker.returns(null); + await fetchMonitorCertificate(sslChecker, monitor).catch((error) => { + expect(error).to.be.an("error"); + expect(error.message).to.equal("Certificate not found"); + }); + }); +}); diff --git a/server/tests/controllers/inviteController.test.js b/server/tests/controllers/inviteController.test.js new file mode 100755 index 000000000..3311f715e --- /dev/null +++ b/server/tests/controllers/inviteController.test.js @@ -0,0 +1,205 @@ +import { + issueInvitation, + inviteVerifyController, +} from "../../controllers/inviteController.js"; +import jwt from "jsonwebtoken"; +import sinon from "sinon"; +import joi from "joi"; +describe("inviteController - issueInvitation", function () { + let req, res, next, stub; + + beforeEach(function () { + req = { + headers: { authorization: "Bearer token" }, + body: { + email: "test@test.com", + role: ["admin"], + teamId: "123", + }, + db: { requestInviteToken: sinon.stub() }, + settingsService: { getSettings: sinon.stub() }, + emailService: { buildAndSendEmail: sinon.stub() }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if role validation fails", async function () { + stub = sinon.stub(jwt, "decode").callsFake(() => { + return { role: ["bad_role"], firstname: "first_name", teamId: "1" }; + }); + await issueInvitation(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0]).to.be.instanceOf(joi.ValidationError); + expect(next.firstCall.args[0].status).to.equal(422); + stub.restore(); + }); + + it("should reject with an error if body validation fails", async function () { + stub = sinon.stub(jwt, "decode").callsFake(() => { + return { role: ["admin"], firstname: "first_name", teamId: "1" }; + }); + req.body = {}; + await issueInvitation(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + stub.restore(); + }); + + it("should reject with an error if DB operations fail", async function () { + stub = sinon.stub(jwt, "decode").callsFake(() => { + return { role: ["admin"], firstname: "first_name", teamId: "1" }; + }); + req.db.requestInviteToken.throws(new Error("DB error")); + await issueInvitation(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + stub.restore(); + }); + + it("should send an invite successfully", async function () { + const token = "token"; + const decodedToken = { + role: "admin", + firstname: "John", + teamId: "team123", + }; + const inviteToken = { token: "inviteToken" }; + const clientHost = "http://localhost"; + + stub = sinon.stub(jwt, "decode").callsFake(() => { + return decodedToken; + }); + req.db.requestInviteToken.resolves(inviteToken); + req.settingsService.getSettings.returns({ clientHost }); + req.emailService.buildAndSendEmail.resolves(); + await issueInvitation(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: "Invite sent", + data: inviteToken, + }) + ).to.be.true; + stub.restore(); + }); + + it("should send an email successfully", async function () { + const token = "token"; + const decodedToken = { + role: "admin", + firstname: "John", + teamId: "team123", + }; + const inviteToken = { token: "inviteToken" }; + const clientHost = "http://localhost"; + + stub = sinon.stub(jwt, "decode").callsFake(() => { + return decodedToken; + }); + req.db.requestInviteToken.resolves(inviteToken); + req.settingsService.getSettings.returns({ clientHost }); + req.emailService.buildAndSendEmail.resolves(); + + await issueInvitation(req, res, next); + expect(req.emailService.buildAndSendEmail.calledOnce).to.be.true; + expect( + req.emailService.buildAndSendEmail.calledWith( + "employeeActivationTemplate", + { + name: "John", + link: "http://localhost/register/inviteToken", + }, + "test@test.com", + "Welcome to Uptime Monitor" + ) + ).to.be.true; + stub.restore(); + }); + + it("should continue executing if sending an email fails", async function () { + const token = "token"; + req.emailService.buildAndSendEmail.rejects(new Error("Email error")); + const decodedToken = { + role: "admin", + firstname: "John", + teamId: "team123", + }; + const inviteToken = { token: "inviteToken" }; + const clientHost = "http://localhost"; + + stub = sinon.stub(jwt, "decode").callsFake(() => { + return decodedToken; + }); + req.db.requestInviteToken.resolves(inviteToken); + req.settingsService.getSettings.returns({ clientHost }); + await issueInvitation(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + msg: "Invite sent", + data: inviteToken, + }) + ).to.be.true; + stub.restore(); + }); +}); + +describe("inviteController - inviteVerifyController", function () { + let req, res, next; + + beforeEach(function () { + req = { + body: { token: "token" }, + db: { + getInviteToken: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if body validation fails", async function () { + req.body = {}; + await inviteVerifyController(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async function () { + req.db.getInviteToken.throws(new Error("DB error")); + await inviteVerifyController(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return 200 and invite data when validation and invite retrieval are successful", async function () { + req.db.getInviteToken.resolves({ invite: "data" }); + await inviteVerifyController(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + status: "success", + msg: "Invite verified", + data: { invite: "data" }, + }) + ).to.be.true; + expect(next.called).to.be.false; + }); +}); diff --git a/server/tests/controllers/maintenanceWindowController.test.js b/server/tests/controllers/maintenanceWindowController.test.js new file mode 100755 index 000000000..0ea07b389 --- /dev/null +++ b/server/tests/controllers/maintenanceWindowController.test.js @@ -0,0 +1,422 @@ +import { + createMaintenanceWindows, + getMaintenanceWindowById, + getMaintenanceWindowsByTeamId, + getMaintenanceWindowsByMonitorId, + deleteMaintenanceWindow, + editMaintenanceWindow, +} from "../../controllers/maintenanceWindowController.js"; + +import jwt from "jsonwebtoken"; +import { successMessages } from "../../utils/messages.js"; +import sinon from "sinon"; + +describe("maintenanceWindowController - createMaintenanceWindows", function () { + let req, res, next, stub; + + beforeEach(function () { + req = { + body: { + monitors: ["66ff52e7c5911c61698ac724"], + name: "window", + active: true, + start: "2024-10-11T05:27:13.747Z", + end: "2024-10-11T05:27:14.747Z", + repeat: "123", + }, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + createMaintenanceWindow: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if body validation fails", async function () { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + req.body = {}; + await createMaintenanceWindows(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + stub.restore(); + }); + + it("should reject with an error if jwt.verify fails", async function () { + stub = sinon.stub(jwt, "verify").throws(new jwt.JsonWebTokenError()); + await createMaintenanceWindows(req, res, next); + expect(next.firstCall.args[0]).to.be.instanceOf(jwt.JsonWebTokenError); + stub.restore(); + }); + + it("should reject with an error DB operations fail", async function () { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + req.db.createMaintenanceWindow.throws(new Error("DB error")); + await createMaintenanceWindows(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + stub.restore(); + }); + + it("should return success message if all operations are successful", async function () { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + await createMaintenanceWindows(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(201); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_CREATE, + }) + ).to.be.true; + stub.restore(); + }); + + it("should return success message if all operations are successful with active set to undefined", async function () { + req.body.active = undefined; + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + await createMaintenanceWindows(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(201); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_CREATE, + }) + ).to.be.true; + stub.restore(); + }); +}); + +describe("maintenanceWindowController - getMaintenanceWindowById", function () { + let req, res, next; + + beforeEach(function () { + req = { + body: {}, + params: { + id: "123", + }, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + getMaintenanceWindowById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + it("should reject if param validation fails", async function () { + req.params = {}; + await getMaintenanceWindowById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject if DB operations fail", async function () { + req.db.getMaintenanceWindowById.throws(new Error("DB error")); + await getMaintenanceWindowById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message with data if all operations are successful", async function () { + req.db.getMaintenanceWindowById.returns({ id: "123" }); + await getMaintenanceWindowById(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_GET_BY_ID, + data: { id: "123" }, + }) + ).to.be.true; + }); +}); + +describe("maintenanceWindowController - getMaintenanceWindowsByTeamId", function () { + let req, res, next, stub; + + beforeEach(function () { + req = { + body: {}, + params: {}, + query: {}, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + getMaintenanceWindowsByTeamId: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + it("should reject if query validation fails", async function () { + req.query = { + invalid: 1, + }; + await getMaintenanceWindowsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject if jwt.verify fails", async function () { + stub = sinon.stub(jwt, "verify").throws(new jwt.JsonWebTokenError()); + await getMaintenanceWindowsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.instanceOf(jwt.JsonWebTokenError); + stub.restore(); + }); + + it("should reject with an error if DB operations fail", async function () { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + req.db.getMaintenanceWindowsByTeamId.throws(new Error("DB error")); + await getMaintenanceWindowsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + stub.restore(); + }); + + it("should return success message with data if all operations are successful", async function () { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + req.db.getMaintenanceWindowsByTeamId.returns([{ id: "123" }]); + await getMaintenanceWindowsByTeamId(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_GET_BY_TEAM, + data: [{ id: jwt.verify().teamId }], + }) + ).to.be.true; + stub.restore(); + }); +}); + +describe("maintenanceWindowController - getMaintenanceWindowsByMonitorId", function () { + let req, res, next; + + beforeEach(function () { + req = { + body: {}, + params: { + monitorId: "123", + }, + query: {}, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + getMaintenanceWindowsByMonitorId: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject if param validation fails", async function () { + req.params = {}; + await getMaintenanceWindowsByMonitorId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async function () { + req.db.getMaintenanceWindowsByMonitorId.throws(new Error("DB error")); + await getMaintenanceWindowsByMonitorId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message with data if all operations are successful", async function () { + const data = [{ monitorId: "123" }]; + req.db.getMaintenanceWindowsByMonitorId.returns(data); + await getMaintenanceWindowsByMonitorId(req, res, next); + expect(req.db.getMaintenanceWindowsByMonitorId.calledOnceWith(req.params.monitorId)); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_GET_BY_MONITOR, + data: data, + }) + ).to.be.true; + }); +}); + +describe("maintenanceWindowController - deleteMaintenanceWindow", function () { + let req, res, next; + + beforeEach(function () { + req = { + body: {}, + params: { + id: "123", + }, + query: {}, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + deleteMaintenanceWindowById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject if param validation fails", async function () { + req.params = {}; + await deleteMaintenanceWindow(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async function () { + req.db.deleteMaintenanceWindowById.throws(new Error("DB error")); + await deleteMaintenanceWindow(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message if all operations are successful", async function () { + await deleteMaintenanceWindow(req, res, next); + expect(req.db.deleteMaintenanceWindowById.calledOnceWith(req.params.id)); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_DELETE, + }) + ).to.be.true; + }); +}); + +describe("maintenanceWindowController - editMaintenanceWindow", function () { + let req, res, next; + + beforeEach(function () { + req = { + body: { + active: true, + name: "test", + }, + params: { + id: "123", + }, + query: {}, + headers: { + authorization: "Bearer token", + }, + settingsService: { + getSettings: sinon.stub().returns({ jwtSecret: "jwtSecret" }), + }, + db: { + editMaintenanceWindowById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject if param validation fails", async function () { + req.params = {}; + await editMaintenanceWindow(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject if body validation fails", async function () { + req.body = { invalid: 1 }; + await editMaintenanceWindow(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async function () { + req.db.editMaintenanceWindowById.throws(new Error("DB error")); + await editMaintenanceWindow(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message with data if all operations are successful", async function () { + const data = { id: "123" }; + req.db.editMaintenanceWindowById.returns(data); + + await editMaintenanceWindow(req, res, next); + expect(req.db.editMaintenanceWindowById.calledOnceWith(req.params.id, req.body)); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MAINTENANCE_WINDOW_EDIT, + data: data, + }) + ).to.be.true; + }); +}); diff --git a/server/tests/controllers/monitorController.test.js b/server/tests/controllers/monitorController.test.js new file mode 100755 index 000000000..5aa68b514 --- /dev/null +++ b/server/tests/controllers/monitorController.test.js @@ -0,0 +1,1124 @@ +import { + getAllMonitors, + getAllMonitorsWithUptimeStats, + getMonitorStatsById, + getMonitorCertificate, + getMonitorById, + getMonitorsAndSummaryByTeamId, + getMonitorsByTeamId, + createMonitor, + checkEndpointResolution, + deleteMonitor, + deleteAllMonitors, + editMonitor, + pauseMonitor, + addDemoMonitors, +} from "../../controllers/monitorController.js"; +import jwt from "jsonwebtoken"; +import sinon from "sinon"; +import { successMessages } from "../../utils/messages.js"; +import logger from "../../utils/logger.js"; +import axios from "axios"; +const SERVICE_NAME = "monitorController"; + +describe("Monitor Controller - getAllMonitors", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: {}, + query: {}, + body: {}, + db: { + getAllMonitors: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if DB operations fail", async function () { + req.db.getAllMonitors.throws(new Error("DB error")); + await getAllMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message and data if all operations succeed", async function () { + const data = [{ monitor: "data" }]; + req.db.getAllMonitors.returns(data); + await getAllMonitors(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_GET_ALL, + data: data, + }) + ).to.be.true; + }); +}); +describe("Monitor Controller - getAllMonitorsWithUptimeStats", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: {}, + query: {}, + body: {}, + db: { + getAllMonitorsWithUptimeStats: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if DB operations fail", async function () { + req.db.getAllMonitorsWithUptimeStats.throws(new Error("DB error")); + await getAllMonitorsWithUptimeStats(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message and data if all operations succeed", async function () { + const data = [{ monitor: "data" }]; + req.db.getAllMonitorsWithUptimeStats.returns(data); + await getAllMonitorsWithUptimeStats(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_GET_ALL, + data: data, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - getMonitorStatsById", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorStatsById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + req.params = {}; + await getMonitorStatsById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if query validation fails", async function () { + req.query = { invalid: 1 }; + await getMonitorStatsById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async function () { + req.db.getMonitorStatsById.throws(new Error("DB error")); + await getMonitorStatsById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message and data if all operations succeed", async function () { + const data = [{ monitorStats: "data" }]; + req.db.getMonitorStatsById.returns(data); + await getMonitorStatsById(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_STATS_BY_ID, + data: data, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - getMonitorCertificate", function () { + let req, res, next, fetchMonitorCertificate; + + beforeEach(function () { + req = { + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + fetchMonitorCertificate = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + req.params = {}; + await getMonitorCertificate(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if getMonitorById operation fails", async function () { + req.db.getMonitorById.throws(new Error("DB error")); + await getMonitorCertificate(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message and data if all operations succeed with a valid cert", async function () { + req.db.getMonitorById.returns({ url: "https://www.google.com" }); + const data = { certificate: "cert", validTo: "2024/08/08" }; + fetchMonitorCertificate.returns(data); + await getMonitorCertificate(req, res, next, fetchMonitorCertificate); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_CERTIFICATE, + data: { certificateDate: new Date(data.validTo) }, + }) + ).to.be.true; + }); + + it("should return an error if fetchMonitorCertificate fails", async function () { + req.db.getMonitorById.returns({ url: "https://www.google.com" }); + fetchMonitorCertificate.throws(new Error("Certificate error")); + await getMonitorCertificate(req, res, next, fetchMonitorCertificate); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Certificate error"); + }); +}); + +describe("Monitor Controller - getMonitorById", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorById: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + req.params = {}; + await getMonitorById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if query param validation fails", async function () { + req.query = { invalid: 1 }; + await getMonitorById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async function () { + req.db.getMonitorById.throws(new Error("DB error")); + await getMonitorById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return 404 if a monitor is not found", async function () { + const error = new Error("Monitor not found"); + error.status = 404; + req.db.getMonitorById.throws(error); + await getMonitorById(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(404); + }); + + it("should return success message and data if all operations succeed", async function () { + const data = { monitor: "data" }; + req.db.getMonitorById.returns(data); + await getMonitorById(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_GET_BY_ID, + data: data, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - getMonitorsAndSummaryByTeamId", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: { + teamId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorsAndSummaryByTeamId: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + req.params = {}; + await getMonitorsAndSummaryByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if query validation fails", async function () { + req.query = { invalid: 1 }; + await getMonitorsAndSummaryByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async function () { + req.db.getMonitorsAndSummaryByTeamId.throws(new Error("DB error")); + await getMonitorsAndSummaryByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message and data if all operations succeed", async function () { + const data = { monitors: "data", summary: "data" }; + req.db.getMonitorsAndSummaryByTeamId.returns(data); + await getMonitorsAndSummaryByTeamId(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_GET_BY_USER_ID(req.params.teamId), + data: data, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - getMonitorsByTeamId", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: { + teamId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorsByTeamId: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + req.params = {}; + await getMonitorsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if query validation fails", async function () { + req.query = { invalid: 1 }; + await getMonitorsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB operations fail", async function () { + req.db.getMonitorsByTeamId.throws(new Error("DB error")); + await getMonitorsByTeamId(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should return success message and data if all operations succeed", async function () { + const data = { monitors: "data" }; + req.db.getMonitorsByTeamId.returns(data); + await getMonitorsByTeamId(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_GET_BY_USER_ID(req.params.teamId), + data: data, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - createMonitor", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: {}, + query: {}, + body: { + userId: "123", + teamId: "123", + name: "test_monitor", + description: "test_monitor_desc", + type: "http", + url: "https://example.com", + notifications: [{ email: "example@example.com" }], + }, + db: { + createMonitor: sinon.stub(), + createNotification: sinon.stub(), + }, + jobQueue: { + addJob: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if body validation fails", async function () { + req.body = {}; + await createMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB createMonitor operation fail", async function () { + req.db.createMonitor.throws(new Error("DB error")); + await createMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should reject with an error if DB createNotification operation fail", async function () { + req.db.createNotification.throws(new Error("DB error")); + req.db.createMonitor.returns({ _id: "123" }); + await createMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should reject with an error if monitor.save operation fail", async function () { + req.db.createMonitor.returns({ + _id: "123", + save: sinon.stub().throws(new Error("Monitor save error")), + }); + await createMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Monitor save error"); + }); + + it("should throw an error if addJob operation fails", async function () { + req.db.createMonitor.returns({ _id: "123", save: sinon.stub() }); + req.jobQueue.addJob.throws(new Error("Job error")); + await createMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Job error"); + }); + + it("should return success message and data if all operations succeed", async function () { + const monitor = { _id: "123", save: sinon.stub() }; + req.db.createMonitor.returns(monitor); + await createMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(201); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_CREATE, + data: monitor, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - checkEndpointResolution", function () { + let req, res, next, axiosGetStub; + + beforeEach(function () { + req = { query: { monitorURL: "https://example.com" } }; + res = { status: sinon.stub().returnsThis(), json: sinon.stub() }; + next = sinon.stub(); + axiosGetStub = sinon.stub(axios, "get"); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should resolve the URL successfully", async function () { + axiosGetStub.resolves({ status: 200, statusText: "OK" }); + await checkEndpointResolution(req, res, next); + expect(res.status.calledWith(200)).to.be.true; + expect( + res.json.calledWith({ + success: true, + code: 200, + statusText: "OK", + msg: "URL resolved successfully", + }) + ).to.be.true; + expect(next.called).to.be.false; + }); + + it("should return an error if endpoint resolution fails", async function () { + const axiosError = new Error("resolution failed"); + axiosError.code = "ENOTFOUND"; + axiosGetStub.rejects(axiosError); + await checkEndpointResolution(req, res, next); + expect(next.calledOnce).to.be.true; + const errorPassedToNext = next.getCall(0).args[0]; + expect(errorPassedToNext).to.be.an.instanceOf(Error); + expect(errorPassedToNext.message).to.include("resolution failed"); + expect(errorPassedToNext.code).to.equal("ENOTFOUND"); + expect(errorPassedToNext.status).to.equal(500); + }); + + it("should reject with an error if query validation fails", async function () { + req.query.monitorURL = "invalid-url"; + await checkEndpointResolution(req, res, next); + expect(next.calledOnce).to.be.true; + const error = next.getCall(0).args[0]; + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + expect(error.message).to.equal('"monitorURL" must be a valid uri'); + }); +}); + +describe("Monitor Controller - deleteMonitor", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + deleteMonitor: sinon.stub(), + deleteChecks: sinon.stub(), + deletePageSpeedChecksByMonitorId: sinon.stub(), + deleteNotificationsByMonitorId: sinon.stub(), + }, + jobQueue: { + deleteJob: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + sinon.stub(logger, "error"); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + req.params = {}; + await deleteMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if DB deleteMonitor operation fail", async function () { + req.db.deleteMonitor.throws(new Error("DB error")); + await deleteMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should log an error if deleteJob throws an error", async function () { + const error = new Error("Job error"); + const monitor = { name: "test_monitor", _id: "123" }; + req.db.deleteMonitor.returns(monitor); + req.jobQueue.deleteJob.rejects(error); + await deleteMonitor(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0].message).to.equal( + `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` + ); + }); + + it("should log an error if deleteChecks throws an error", async function () { + const error = new Error("Checks error"); + const monitor = { name: "test_monitor", _id: "123" }; + req.db.deleteMonitor.returns(monitor); + req.db.deleteChecks.rejects(error); + await deleteMonitor(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0].message).to.equal( + `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` + ); + }); + + it("should log an error if deletePageSpeedChecksByMonitorId throws an error", async function () { + const error = new Error("PageSpeed error"); + const monitor = { name: "test_monitor", _id: "123" }; + req.db.deleteMonitor.returns(monitor); + req.db.deletePageSpeedChecksByMonitorId.rejects(error); + await deleteMonitor(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0].message).to.equal( + `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` + ); + }); + + it("should log an error if deleteNotificationsByMonitorId throws an error", async function () { + const error = new Error("Notifications error"); + const monitor = { name: "test_monitor", _id: "123" }; + req.db.deleteMonitor.returns(monitor); + req.db.deleteNotificationsByMonitorId.rejects(error); + await deleteMonitor(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0].message).to.equal( + `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}` + ); + }); + + it("should return success message if all operations succeed", async function () { + const monitor = { name: "test_monitor", _id: "123" }; + req.db.deleteMonitor.returns(monitor); + await deleteMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_DELETE, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - deleteAllMonitors", function () { + let req, res, next, stub; + + beforeEach(function () { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { teamId: "123" }; + }); + req = { + headers: { + authorization: "Bearer token", + }, + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + deleteAllMonitors: sinon.stub(), + deleteChecks: sinon.stub(), + deletePageSpeedChecksByMonitorId: sinon.stub(), + deleteNotificationsByMonitorId: sinon.stub(), + }, + jobQueue: { + deleteJob: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + sinon.stub(logger, "error"); + }); + + afterEach(function () { + sinon.restore(); + stub.restore(); + }); + + it("should reject with an error if getTokenFromHeaders throws an error", async function () { + req.headers = {}; + await deleteAllMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("No auth headers"); + expect(next.firstCall.args[0].status).to.equal(500); + }); + + it("should reject with an error if token validation fails", async function () { + stub.restore(); + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + await deleteAllMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.instanceOf(jwt.JsonWebTokenError); + }); + + it("should reject with an error if DB deleteAllMonitors operation fail", async function () { + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.deleteAllMonitors.throws(new Error("DB error")); + await deleteAllMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should log an error if deleteChecks throws an error", async function () { + const monitors = [{ name: "test_monitor", _id: "123" }]; + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.deleteAllMonitors.returns({ monitors, deletedCount: 1 }); + const error = new Error("Check error"); + req.db.deleteChecks.rejects(error); + await deleteAllMonitors(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0].message).to.equal( + `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` + ); + }); + + it("should log an error if deletePageSpeedChecksByMonitorId throws an error", async function () { + const monitors = [{ name: "test_monitor", _id: "123" }]; + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.deleteAllMonitors.returns({ monitors, deletedCount: 1 }); + const error = new Error("Pagespeed Check error"); + req.db.deletePageSpeedChecksByMonitorId.rejects(error); + await deleteAllMonitors(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0].message).to.equal( + `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` + ); + }); + + it("should log an error if deleteNotificationsByMonitorId throws an error", async function () { + const monitors = [{ name: "test_monitor", _id: "123" }]; + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.deleteAllMonitors.returns({ monitors, deletedCount: 1 }); + const error = new Error("Notifications Check error"); + req.db.deleteNotificationsByMonitorId.rejects(error); + await deleteAllMonitors(req, res, next); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0].message).to.equal( + `Error deleting associated records for monitor ${monitors[0]._id} with name ${monitors[0].name}` + ); + }); + + it("should return success message if all operations succeed", async function () { + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.deleteAllMonitors.returns({ + monitors: [{ name: "test_monitor", _id: "123" }], + deletedCount: 1, + }); + await deleteAllMonitors(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: "Deleted 1 monitors", + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - editMonitor", function () { + let req, res, next; + + beforeEach(function () { + req = { + headers: {}, + params: { + monitorId: "123", + }, + query: {}, + body: { + notifications: [{ email: "example@example.com" }], + }, + db: { + getMonitorById: sinon.stub(), + editMonitor: sinon.stub(), + deleteNotificationsByMonitorId: sinon.stub(), + createNotification: sinon.stub(), + }, + jobQueue: { + deleteJob: sinon.stub(), + addJob: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + req.params = {}; + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if body validation fails", async function () { + req.body = { invalid: 1 }; + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if getMonitorById operation fails", async function () { + req.db.getMonitorById.throws(new Error("DB error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should reject with an error if editMonitor operation fails", async function () { + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.throws(new Error("DB error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should reject with an error if deleteNotificationsByMonitorId operation fails", async function () { + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns({ _id: "123" }); + req.db.deleteNotificationsByMonitorId.throws(new Error("DB error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should reject with an error if createNotification operation fails", async function () { + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns({ _id: "123" }); + req.db.createNotification.throws(new Error("DB error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should reject with an error if deleteJob operation fails", async function () { + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns({ _id: "123" }); + req.jobQueue.deleteJob.throws(new Error("Job error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Job error"); + }); + + it("should reject with an error if addJob operation fails", async function () { + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns({ _id: "123" }); + req.jobQueue.addJob.throws(new Error("Add Job error")); + await editMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Add Job error"); + }); + + it("should return success message with data if all operations succeed", async function () { + const monitor = { _id: "123" }; + req.db.getMonitorById.returns({ teamId: "123" }); + req.db.editMonitor.returns(monitor); + await editMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_EDIT, + data: monitor, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - pauseMonitor", function () { + let req, res, next; + + beforeEach(function () { + req = { + headers: {}, + params: { + monitorId: "123", + }, + query: {}, + body: {}, + db: { + getMonitorById: sinon.stub(), + }, + jobQueue: { + deleteJob: sinon.stub(), + addJob: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should reject with an error if param validation fails", async function () { + req.params = {}; + await pauseMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if getMonitorById operation fails", async function () { + req.db.getMonitorById.throws(new Error("DB error")); + await pauseMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should reject with an error if deleteJob operation fails", async function () { + const monitor = { _id: req.params.monitorId, isActive: true }; + req.db.getMonitorById.returns(monitor); + req.jobQueue.deleteJob.throws(new Error("Delete Job error")); + await pauseMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Delete Job error"); + }); + + it("should reject with an error if addJob operation fails", async function () { + const monitor = { _id: req.params.monitorId, isActive: false }; + req.db.getMonitorById.returns(monitor); + req.jobQueue.addJob.throws(new Error("Add Job error")); + await pauseMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Add Job error"); + }); + + it("should reject with an error if monitor.save operation fails", async function () { + const monitor = { + _id: req.params.monitorId, + active: false, + save: sinon.stub().throws(new Error("Save error")), + }; + req.db.getMonitorById.returns(monitor); + await pauseMonitor(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Save error"); + }); + + it("should return success pause message with data if all operations succeed with inactive monitor", async function () { + const monitor = { + _id: req.params.monitorId, + isActive: false, + save: sinon.stub().resolves(), + }; + req.db.getMonitorById.returns(monitor); + await pauseMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_PAUSE, + data: monitor, + }) + ).to.be.true; + }); + + it("should return success resume message with data if all operations succeed with active monitor", async function () { + const monitor = { + _id: req.params.monitorId, + isActive: true, + save: sinon.stub().resolves(), + }; + req.db.getMonitorById.returns(monitor); + await pauseMonitor(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_RESUME, + data: monitor, + }) + ).to.be.true; + }); +}); + +describe("Monitor Controller - addDemoMonitors", function () { + let req, res, next, stub; + + beforeEach(function () { + stub = sinon.stub(jwt, "verify").callsFake(() => { + return { _id: "123", teamId: "123" }; + }); + req = { + headers: { + authorization: "Bearer token", + }, + params: {}, + query: {}, + body: {}, + db: { + addDemoMonitors: sinon.stub(), + }, + settingsService: { + getSettings: sinon.stub(), + }, + jobQueue: { + addJob: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + stub.restore(); + }); + + it("should reject with an error if getTokenFromHeaders fails", async function () { + req.headers = {}; + await addDemoMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("No auth headers"); + expect(next.firstCall.args[0].status).to.equal(500); + }); + + it("should reject with an error if getting settings fails", async function () { + req.settingsService.getSettings.throws(new Error("Settings error")); + await addDemoMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Settings error"); + }); + + it("should reject with an error if JWT validation fails", async function () { + stub.restore(); + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + await addDemoMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.instanceOf(jwt.JsonWebTokenError); + }); + + it("should reject with an error if addDemoMonitors operation fails", async function () { + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.addDemoMonitors.throws(new Error("DB error")); + await addDemoMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("DB error"); + }); + + it("should reject with an error if addJob operation fails", async function () { + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.addDemoMonitors.returns([{ _id: "123" }]); + req.jobQueue.addJob.throws(new Error("Add Job error")); + await addDemoMonitors(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("Add Job error"); + }); + + it("should return success message with data if all operations succeed", async function () { + const monitors = [{ _id: "123" }]; + req.settingsService.getSettings.returns({ jwtSecret: "my_secret" }); + req.db.addDemoMonitors.returns(monitors); + await addDemoMonitors(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect( + res.json.calledOnceWith({ + success: true, + msg: successMessages.MONITOR_DEMO_ADDED, + data: monitors.length, + }) + ).to.be.true; + }); +}); diff --git a/server/tests/controllers/queueController.test.js b/server/tests/controllers/queueController.test.js new file mode 100755 index 000000000..72450a0bd --- /dev/null +++ b/server/tests/controllers/queueController.test.js @@ -0,0 +1,181 @@ +import { afterEach } from "node:test"; +import { + getMetrics, + getJobs, + addJob, + obliterateQueue, +} from "../../controllers/queueController.js"; +import { successMessages } from "../../utils/messages.js"; +import sinon from "sinon"; + +describe("Queue Controller - getMetrics", function () { + let req, res, next; + + beforeEach(function () { + req = { + headers: {}, + params: {}, + body: {}, + db: {}, + jobQueue: { + getMetrics: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should throw an error if getMetrics throws an error", async function () { + req.jobQueue.getMetrics.throws(new Error("getMetrics error")); + await getMetrics(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getMetrics error"); + }); + + it("should return a success message and data if getMetrics is successful", async function () { + const data = { data: "metrics" }; + req.jobQueue.getMetrics.returns(data); + await getMetrics(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.QUEUE_GET_METRICS, + data, + }); + }); +}); + +describe("Queue Controller - getJobs", function () { + let req, res, next; + + beforeEach(function () { + req = { + headers: {}, + params: {}, + body: {}, + db: {}, + jobQueue: { + getJobStats: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if getJobs throws an error", async function () { + req.jobQueue.getJobStats.throws(new Error("getJobs error")); + await getJobs(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getJobs error"); + }); + + it("should return a success message and data if getJobs is successful", async function () { + const data = { data: "jobs" }; + req.jobQueue.getJobStats.returns(data); + await getJobs(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.QUEUE_GET_METRICS, + data, + }); + }); +}); + +describe("Queue Controller - addJob", function () { + let req, res, next; + + beforeEach(function () { + req = { + headers: {}, + params: {}, + body: {}, + db: {}, + jobQueue: { + addJob: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if addJob throws an error", async function () { + req.jobQueue.addJob.throws(new Error("addJob error")); + await addJob(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("addJob error"); + }); + + it("should return a success message if addJob is successful", async function () { + req.jobQueue.addJob.resolves(); + await addJob(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.QUEUE_ADD_JOB, + }); + }); +}); + +describe("Queue Controller - obliterateQueue", function () { + let req, res, next; + + beforeEach(function () { + req = { + headers: {}, + params: {}, + body: {}, + db: {}, + jobQueue: { + obliterate: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if obliterateQueue throws an error", async function () { + req.jobQueue.obliterate.throws(new Error("obliterateQueue error")); + await obliterateQueue(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("obliterateQueue error"); + }); + + it("should return a success message if obliterateQueue is successful", async function () { + req.jobQueue.obliterate.resolves(); + await obliterateQueue(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.QUEUE_OBLITERATE, + }); + }); +}); diff --git a/server/tests/controllers/settingsController.test.js b/server/tests/controllers/settingsController.test.js new file mode 100755 index 000000000..0f8e628af --- /dev/null +++ b/server/tests/controllers/settingsController.test.js @@ -0,0 +1,112 @@ +import { afterEach } from "node:test"; +import { + getAppSettings, + updateAppSettings, +} from "../../controllers/settingsController.js"; + +import { successMessages } from "../../utils/messages.js"; +import sinon from "sinon"; + +describe("Settings Controller - getAppSettings", function () { + let req, res, next; + + beforeEach(function () { + req = { + headers: {}, + params: {}, + body: {}, + db: {}, + settingsService: { + getSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should throw an error if getSettings throws an error", async function () { + req.settingsService.getSettings.throws(new Error("getSettings error")); + await getAppSettings(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("getSettings error"); + }); + + it("should return a success message and data if getSettings is successful", async function () { + const data = { data: "settings" }; + req.settingsService.getSettings.returns(data); + await getAppSettings(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.GET_APP_SETTINGS, + data, + }); + }); +}); + +describe("Settings Controller - updateAppSettings", function () { + let req, res, next; + + beforeEach(function () { + req = { + headers: {}, + params: {}, + body: {}, + db: { + updateAppSettings: sinon.stub(), + }, + settingsService: { + reloadSettings: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should reject with an error if body validation fails", async function () { + req.body = { invalid: 1 }; + await updateAppSettings(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].status).to.equal(422); + }); + + it("should reject with an error if updateAppSettings throws an error", async function () { + req.db.updateAppSettings.throws(new Error("updateAppSettings error")); + await updateAppSettings(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("updateAppSettings error"); + }); + + it("should reject with an error if reloadSettings throws an error", async function () { + req.settingsService.reloadSettings.throws(new Error("reloadSettings error")); + await updateAppSettings(req, res, next); + expect(next.firstCall.args[0]).to.be.an("error"); + expect(next.firstCall.args[0].message).to.equal("reloadSettings error"); + }); + + it("should return a success message and data if updateAppSettings is successful", async function () { + const data = { data: "settings" }; + req.settingsService.reloadSettings.returns(data); + await updateAppSettings(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0]).to.deep.equal({ + success: true, + msg: successMessages.UPDATE_APP_SETTINGS, + data, + }); + }); +}); diff --git a/server/tests/controllers/statusPageController.test.js b/server/tests/controllers/statusPageController.test.js new file mode 100755 index 000000000..3247ca975 --- /dev/null +++ b/server/tests/controllers/statusPageController.test.js @@ -0,0 +1,130 @@ +import sinon from "sinon"; +import { + createStatusPage, + getStatusPageByUrl, +} from "../../controllers/statusPageController.js"; + +describe("statusPageController", function () { + let req, res, next; + + beforeEach(function () { + req = { + params: {}, + body: {}, + db: { + createStatusPage: sinon.stub(), + getStatusPageByUrl: sinon.stub(), + }, + }; + res = { + status: sinon.stub().returnsThis(), + json: sinon.stub(), + }; + next = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("createStatusPage", function () { + beforeEach(function () { + req.body = { + companyName: "Test Company", + url: "123456", + timezone: "America/Toronto", + color: "#000000", + theme: "light", + monitors: ["67309ca673788e808884c8ac", "67309ca673788e808884c8ac"], + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should handle a validation error", async function () { + req.body = { + // Invalid data that will trigger validation error + companyName: "", + url: "", + timezone: "", + color: "invalid", + theme: "invalid", + monitors: ["invalid-id"], + }; + try { + await createStatusPage(req, res, next); + } catch (error) { + expect(error).to.be.an.instanceOf(Error); + expect(error.message).to.equal("Validation error"); + } + }); + + it("should handle a db error", async function () { + const err = new Error("DB error"); + req.db.createStatusPage.throws(err); + + try { + await createStatusPage(req, res, next); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + + it("should insert a properly formatted status page", async function () { + const result = await createStatusPage(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0].success).to.be.true; + }); + }); + + describe("getStatusPageByUrl", function () { + beforeEach(function () { + req.params = { + url: "123456", + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should handle a validation error", async function () { + req.params = { + url: "", + }; + + try { + await getStatusPageByUrl(req, res, next); + } catch (error) { + expect(error).to.be.an.instanceOf(Error); + expect(error.message).to.equal("Validation error"); + } + }); + + it("should handle a DB error", async function () { + const err = new Error("DB error"); + req.db.getStatusPageByUrl.throws(err); + + try { + await getStatusPageByUrl(req, res, next); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + + it("should return a status page", async function () { + const statusPage = { + _id: "123456", + companyName: "Test Company", + url: "123456", + }; + req.db.getStatusPageByUrl.resolves(statusPage); + const result = await getStatusPageByUrl(req, res, next); + expect(res.status.firstCall.args[0]).to.equal(200); + expect(res.json.firstCall.args[0].success).to.be.true; + expect(res.json.firstCall.args[0].data).to.deep.equal(statusPage); + }); + }); +}); diff --git a/server/tests/db/checkModule.test.js b/server/tests/db/checkModule.test.js new file mode 100755 index 000000000..7ef2f5b77 --- /dev/null +++ b/server/tests/db/checkModule.test.js @@ -0,0 +1,593 @@ +import sinon from "sinon"; +import { + createCheck, + getChecksCount, + getChecks, + getTeamChecks, + deleteChecks, + deleteChecksByTeamId, + updateChecksTTL, +} from "../../db/mongo/modules/checkModule.js"; +import Check from "../../db/models/Check.js"; +import Monitor from "../../db/models/Monitor.js"; +import User from "../../db/models/User.js"; +import logger from "../../utils/logger.js"; + +describe("checkModule", function () { + describe("createCheck", function () { + let checkCountDocumentsStub, checkSaveStub, monitorFindByIdStub, monitorSaveStub; + const mockMonitor = { + _id: "123", + uptimePercentage: 0.5, + status: true, + save: () => this, + }; + const mockCheck = { active: true }; + + beforeEach(function () { + checkSaveStub = sinon.stub(Check.prototype, "save"); + checkCountDocumentsStub = sinon.stub(Check, "countDocuments"); + monitorFindByIdStub = sinon.stub(Monitor, "findById"); + monitorSaveStub = sinon.stub(Monitor.prototype, "save"); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return undefined early if no monitor is found", async function () { + monitorFindByIdStub.returns(null); + const check = await createCheck({ monitorId: "123" }); + expect(check).to.be.undefined; + }); + + it("should return a check", async function () { + monitorFindByIdStub.returns(mockMonitor); + checkSaveStub.returns(mockCheck); + monitorSaveStub.returns(mockMonitor); + const check = await createCheck({ monitorId: "123", status: true }); + expect(check).to.deep.equal(mockCheck); + }); + + it("should return a check if status is down", async function () { + mockMonitor.status = false; + monitorFindByIdStub.returns(mockMonitor); + checkSaveStub.returns(mockCheck); + monitorSaveStub.returns(mockMonitor); + const check = await createCheck({ monitorId: "123", status: false }); + expect(check).to.deep.equal(mockCheck); + }); + + it("should return a check if uptimePercentage is undefined", async function () { + mockMonitor.uptimePercentage = undefined; + monitorFindByIdStub.returns(mockMonitor); + checkSaveStub.returns(mockCheck); + monitorSaveStub.returns(mockMonitor); + const check = await createCheck({ monitorId: "123", status: true }); + expect(check).to.deep.equal(mockCheck); + }); + + it("should return a check if uptimePercentage is undefined and status is down", async function () { + mockMonitor.uptimePercentage = undefined; + monitorFindByIdStub.returns(mockMonitor); + checkSaveStub.returns(mockCheck); + monitorSaveStub.returns(mockMonitor); + const check = await createCheck({ monitorId: "123", status: false }); + expect(check).to.deep.equal(mockCheck); + }); + + it("should monitor save error", async function () { + const err = new Error("Save Error"); + monitorSaveStub.throws(err); + try { + await createCheck({ monitorId: "123" }); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + + it("should handle errors", async function () { + const err = new Error("DB Error"); + checkCountDocumentsStub.throws(err); + try { + await createCheck({ monitorId: "123" }); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getChecksCount", function () { + let checkCountDocumentStub; + + beforeEach(function () { + checkCountDocumentStub = sinon.stub(Check, "countDocuments"); + }); + + afterEach(function () { + checkCountDocumentStub.restore(); + }); + + it("should return count with basic monitorId query", async function () { + const req = { + params: { monitorId: "test123" }, + query: {}, + }; + checkCountDocumentStub.resolves(5); + + const result = await getChecksCount(req); + + expect(result).to.equal(5); + expect(checkCountDocumentStub.calledOnce).to.be.true; + expect(checkCountDocumentStub.firstCall.args[0]).to.deep.equal({ + monitorId: "test123", + }); + }); + + it("should include dateRange in query when provided", async function () { + const req = { + params: { monitorId: "test123" }, + query: { dateRange: "day" }, + }; + checkCountDocumentStub.resolves(3); + + const result = await getChecksCount(req); + + expect(result).to.equal(3); + expect(checkCountDocumentStub.firstCall.args[0]).to.have.property("createdAt"); + expect(checkCountDocumentStub.firstCall.args[0].createdAt).to.have.property("$gte"); + }); + + it('should handle "all" filter correctly', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "all" }, + }; + checkCountDocumentStub.resolves(2); + + const result = await getChecksCount(req); + + expect(result).to.equal(2); + expect(checkCountDocumentStub.firstCall.args[0]).to.deep.equal({ + monitorId: "test123", + status: false, + }); + }); + + it('should handle "down" filter correctly', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "down" }, + }; + checkCountDocumentStub.resolves(2); + + const result = await getChecksCount(req); + + expect(result).to.equal(2); + expect(checkCountDocumentStub.firstCall.args[0]).to.deep.equal({ + monitorId: "test123", + status: false, + }); + }); + + it('should handle "resolve" filter correctly', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "resolve" }, + }; + checkCountDocumentStub.resolves(1); + + const result = await getChecksCount(req); + + expect(result).to.equal(1); + expect(checkCountDocumentStub.firstCall.args[0]).to.deep.equal({ + monitorId: "test123", + status: false, + statusCode: 5000, + }); + }); + + it("should handle unknown filter correctly", async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "unknown" }, + }; + checkCountDocumentStub.resolves(1); + + const result = await getChecksCount(req); + + expect(result).to.equal(1); + expect(checkCountDocumentStub.firstCall.args[0]).to.deep.equal({ + monitorId: "test123", + status: false, + }); + }); + + it("should combine dateRange and filter in query", async function () { + const req = { + params: { monitorId: "test123" }, + query: { + dateRange: "week", + filter: "down", + }, + }; + checkCountDocumentStub.resolves(4); + + const result = await getChecksCount(req); + + expect(result).to.equal(4); + expect(checkCountDocumentStub.firstCall.args[0]).to.have.all.keys( + "monitorId", + "createdAt", + "status" + ); + }); + }); + + describe("getChecks", function () { + let checkFindStub, monitorFindStub; + + beforeEach(function () { + checkFindStub = sinon.stub(Check, "find").returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns([{ id: 1 }, { id: 2 }]), + }), + }), + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return checks with basic monitorId query", async function () { + const req = { + params: { monitorId: "test123" }, + query: {}, + }; + + const result = await getChecks(req); + + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it("should return checks with limit query", async function () { + const req = { + params: { monitorId: "test123" }, + query: { limit: 10 }, + }; + + const result = await getChecks(req); + + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it("should handle pagination correctly", async function () { + const req = { + params: { monitorId: "test123" }, + query: { + page: 2, + rowsPerPage: 10, + }, + }; + + const result = await getChecks(req); + + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it("should handle dateRange filter", async function () { + const req = { + params: { monitorId: "test123" }, + query: { dateRange: "week" }, + }; + const result = await getChecks(req); + + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it('should handle "all" filter', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "all" }, + }; + + await getChecks(req); + const result = await getChecks(req); + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it('should handle "down" filter', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "down" }, + }; + + await getChecks(req); + const result = await getChecks(req); + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it('should handle "resolve" filter', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "resolve" }, + }; + + await getChecks(req); + const result = await getChecks(req); + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it('should handle "unknown" filter', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "unknown" }, + }; + + await getChecks(req); + const result = await getChecks(req); + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it("should handle ascending sort order", async function () { + const req = { + params: { monitorId: "test123" }, + query: { sortOrder: "asc" }, + }; + + await getChecks(req); + const result = await getChecks(req); + expect(result).to.deep.equal([{ id: 1 }, { id: 2 }]); + }); + + it("should handle error case", async function () { + const req = { + params: { monitorId: "test123" }, + query: {}, + }; + + checkFindStub.throws(new Error("Database error")); + + try { + await getChecks(req); + } catch (error) { + expect(error.message).to.equal("Database error"); + expect(error.service).to.equal("checkModule"); + expect(error.method).to.equal("getChecks"); + } + }); + }); + + describe("getTeamChecks", function () { + let checkFindStub, checkCountDocumentsStub, monitorFindStub; + const mockMonitors = [{ _id: "123" }]; + + beforeEach(function () { + monitorFindStub = sinon.stub(Monitor, "find").returns({ + select: sinon.stub().returns(mockMonitors), + }); + checkCountDocumentsStub = sinon.stub(Check, "countDocuments").returns(2); + checkFindStub = sinon.stub(Check, "find").returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns({ + select: sinon.stub().returns([{ id: 1 }, { id: 2 }]), + }), + }), + }), + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return checks with basic monitorId query", async function () { + const req = { + params: { teamId: "test123" }, + query: {}, + }; + + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it("should handle pagination correctly", async function () { + const req = { + params: { monitorId: "test123" }, + query: { limit: 1, page: 2, rowsPerPage: 10 }, + }; + + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it("should handle dateRange filter", async function () { + const req = { + params: { monitorId: "test123" }, + query: { dateRange: "week" }, + }; + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it('should handle "all" filter', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "all" }, + }; + + await getChecks(req); + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it('should handle "down" filter', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "down" }, + }; + + await getChecks(req); + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it('should handle "resolve" filter', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "resolve" }, + }; + + await getChecks(req); + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it('should handle "unknown" filter', async function () { + const req = { + params: { monitorId: "test123" }, + query: { filter: "unknown" }, + }; + + await getChecks(req); + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it("should handle ascending sort order", async function () { + const req = { + params: { monitorId: "test123" }, + query: { sortOrder: "asc" }, + }; + + await getChecks(req); + const result = await getTeamChecks(req); + expect(result).to.deep.equal({ checksCount: 2, checks: [{ id: 1 }, { id: 2 }] }); + }); + + it("should handle error case", async function () { + const req = { + params: { monitorId: "test123" }, + query: {}, + }; + + checkFindStub.throws(new Error("Database error")); + + try { + await getTeamChecks(req); + } catch (error) { + expect(error.message).to.equal("Database error"); + expect(error.service).to.equal("checkModule"); + expect(error.method).to.equal("getTeamChecks"); + } + }); + }); + + describe("deleteChecks", function () { + let checkDeleteManyStub; + + beforeEach(function () { + checkDeleteManyStub = sinon.stub(Check, "deleteMany").resolves({ deletedCount: 1 }); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return a value if a check is deleted", async function () { + const result = await deleteChecks("123"); + expect(result).to.equal(1); + }); + + it("should handle an error", async function () { + checkDeleteManyStub.throws(new Error("Database error")); + try { + await deleteChecks("123"); + } catch (error) { + expect(error.message).to.equal("Database error"); + expect(error.method).to.equal("deleteChecks"); + } + }); + }); + + describe("deleteChecksByTeamId", function () { + let mockMonitors = [{ _id: 123, save: () => this }]; + let monitorFindStub, monitorSaveStub, checkDeleteManyStub; + + beforeEach(function () { + monitorSaveStub = sinon.stub(Monitor.prototype, "save"); + monitorFindStub = sinon.stub(Monitor, "find").returns(mockMonitors); + checkDeleteManyStub = sinon.stub(Check, "deleteMany").resolves({ deletedCount: 1 }); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return a deleted count", async function () { + const result = await deleteChecksByTeamId("123"); + expect(result).to.equal(1); + }); + + it("should handle errors", async function () { + const err = new Error("DB Error"); + monitorFindStub.throws(err); + try { + const result = await deleteChecksByTeamId("123"); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("updateChecksTTL", function () { + let userUpdateManyStub; + let loggerStub; + + beforeEach(function () { + loggerStub = sinon.stub(logger, "error"); + userUpdateManyStub = sinon.stub(User, "updateMany"); + Check.collection = { dropIndex: sinon.stub(), createIndex: sinon.stub() }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return undefined", async function () { + const result = await updateChecksTTL("123", 10); + expect(result).to.be.undefined; + }); + + it("should log an error if dropIndex throws an error", async function () { + const err = new Error("Drop Index Error"); + Check.collection.dropIndex.throws(err); + await updateChecksTTL("123", 10); + expect(loggerStub.calledOnce).to.be.true; + expect(loggerStub.firstCall.args[0].message).to.equal(err.message); + }); + + it("should throw an error if createIndex throws an error", async function () { + const err = new Error("Create Index Error"); + Check.collection.createIndex.throws(err); + try { + await updateChecksTTL("123", 10); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + + it("should throw an error if User.updateMany throws an error", async function () { + const err = new Error("Update Many Error"); + userUpdateManyStub.throws(err); + try { + await updateChecksTTL("123", 10); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); +}); diff --git a/server/tests/db/hardwareCheckModule.test.js b/server/tests/db/hardwareCheckModule.test.js new file mode 100755 index 000000000..566c45a8e --- /dev/null +++ b/server/tests/db/hardwareCheckModule.test.js @@ -0,0 +1,139 @@ +import sinon from "sinon"; +import HardwareCheck from "../../db/models/HardwareCheck.js"; +import { createHardwareCheck } from "../../db/mongo/modules/hardwareCheckModule.js"; +import Monitor from "../../db/models/Monitor.js"; +import logger from "../../utils/logger.js"; + +const mockHardwareCheck = { + data: { + cpu: { + physical_core: 4, + logical_core: 8, + frequency: 4800, + current_frequency: 1411, + temperature: [45, 50, 46, 47, 45, 50, 46, 47], + free_percent: 0.8552990910595134, + usage_percent: 0.14470090894048657, + }, + memory: { + total_bytes: 16467628032, + available_bytes: 7895044096, + used_bytes: 6599561216, + usage_percent: 0.4008, + }, + disk: [ + { + read_speed_bytes: null, + write_speed_bytes: null, + total_bytes: 931258499072, + free_bytes: 737097256960, + usage_percent: 0.1661, + }, + ], + host: { + os: "linux", + platform: "ubuntu", + kernel_version: "6.8.0-48-generic", + }, + }, + errors: [ + { + metric: ["cpu.temperature"], + err: "unable to read CPU temperature", + }, + ], +}; + +const mockMonitor = { + _id: "123", + uptimePercentage: 1, + status: true, + save: () => this, +}; + +describe("HardwareCheckModule", function () { + let hardwareCheckSaveStub, + hardwareCheckCountDocumentsStub, + monitorFindByIdStub, + loggerStub; + + beforeEach(function () { + loggerStub = sinon.stub(logger, "error"); + hardwareCheckSaveStub = sinon.stub(HardwareCheck.prototype, "save"); + monitorFindByIdStub = sinon.stub(Monitor, "findById"); + hardwareCheckCountDocumentsStub = sinon.stub(HardwareCheck, "countDocuments"); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("createHardwareCheck", function () { + it("should return a hardware check", async function () { + hardwareCheckSaveStub.resolves(mockHardwareCheck); + monitorFindByIdStub.resolves(mockMonitor); + hardwareCheckCountDocumentsStub.resolves(1); + const hardwareCheck = await createHardwareCheck({ status: true }); + expect(hardwareCheck).to.exist; + expect(hardwareCheck).to.deep.equal(mockHardwareCheck); + }); + + it("should return a hardware check for a check with status false", async function () { + hardwareCheckSaveStub.resolves(mockHardwareCheck); + monitorFindByIdStub.resolves(mockMonitor); + hardwareCheckCountDocumentsStub.resolves(1); + const hardwareCheck = await createHardwareCheck({ status: false }); + expect(hardwareCheck).to.exist; + expect(hardwareCheck).to.deep.equal(mockHardwareCheck); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + monitorFindByIdStub.resolves(mockMonitor); + hardwareCheckSaveStub.rejects(err); + try { + await createHardwareCheck({}); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + + it("should log an error if a monitor is not found", async function () { + monitorFindByIdStub.resolves(null); + const res = await createHardwareCheck({}); + expect(loggerStub.calledOnce).to.be.true; + expect(res).to.be.null; + }); + + it("should handle a monitor with undefined uptimePercentage", async function () { + monitorFindByIdStub.resolves({ ...mockMonitor, uptimePercentage: undefined }); + hardwareCheckSaveStub.resolves(mockHardwareCheck); + hardwareCheckCountDocumentsStub.resolves(1); + const res = await createHardwareCheck({}); + expect(res).to.exist; + }); + + it("should handle a monitor with undefined uptimePercentage and true status", async function () { + monitorFindByIdStub.resolves({ + ...mockMonitor, + uptimePercentage: undefined, + }); + hardwareCheckSaveStub.resolves(mockHardwareCheck); + hardwareCheckCountDocumentsStub.resolves(1); + const res = await createHardwareCheck({ status: true }); + expect(res).to.exist; + }); + + it("should handle a monitor with undefined uptimePercentage and false status", async function () { + monitorFindByIdStub.resolves({ + ...mockMonitor, + uptimePercentage: undefined, + }); + hardwareCheckSaveStub.resolves(mockHardwareCheck); + hardwareCheckCountDocumentsStub.resolves(1); + const res = await createHardwareCheck({ status: false }); + expect(res).to.exist; + }); + }); +}); diff --git a/server/tests/db/inviteModule.test.js b/server/tests/db/inviteModule.test.js new file mode 100755 index 000000000..9b3aab9cc --- /dev/null +++ b/server/tests/db/inviteModule.test.js @@ -0,0 +1,110 @@ +import sinon from "sinon"; +import InviteToken from "../../db/models/InviteToken.js"; +import { + requestInviteToken, + getInviteToken, + getInviteTokenAndDelete, +} from "../../db/mongo/modules/inviteModule.js"; +import { errorMessages } from "../../utils/messages.js"; + +describe("Invite Module", function () { + const mockUserData = { + email: "test@test.com", + teamId: "123", + role: ["admin"], + token: "123", + }; + const mockInviteToken = { _id: 123, time: 123 }; + let inviteTokenDeleteManyStub, + inviteTokenSaveStub, + inviteTokenFindOneStub, + inviteTokenFindOneAndDeleteStub; + + beforeEach(function () { + inviteTokenDeleteManyStub = sinon.stub(InviteToken, "deleteMany"); + inviteTokenSaveStub = sinon.stub(InviteToken.prototype, "save"); + inviteTokenFindOneStub = sinon.stub(InviteToken, "findOne"); + inviteTokenFindOneAndDeleteStub = sinon.stub(InviteToken, "findOneAndDelete"); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("requestInviteToken", function () { + it("should return a new invite token", async function () { + inviteTokenDeleteManyStub.resolves(); + inviteTokenSaveStub.resolves(); + const inviteToken = await requestInviteToken(mockUserData); + expect(inviteToken.email).to.equal(mockUserData.email); + expect(inviteToken.role).to.deep.equal(mockUserData.role); + expect(inviteToken.token).to.exist; + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + inviteTokenDeleteManyStub.rejects(err); + try { + await requestInviteToken(mockUserData); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getInviteToken", function () { + it("should return an invite token", async function () { + inviteTokenFindOneStub.resolves(mockInviteToken); + const inviteToken = await getInviteToken(mockUserData.token); + expect(inviteToken).to.deep.equal(mockInviteToken); + }); + + it("should handle a token not found", async function () { + inviteTokenFindOneStub.resolves(null); + try { + await getInviteToken(mockUserData.token); + } catch (error) { + expect(error.message).to.equal(errorMessages.AUTH_INVITE_NOT_FOUND); + } + }); + + it("should handle DB errors", async function () { + const err = new Error("test error"); + inviteTokenFindOneStub.rejects(err); + try { + await getInviteToken(mockUserData.token); + } catch (error) { + expect(error).to.deep.equal(err); + expect(error.method).to.equal("getInviteToken"); + } + }); + }); + + describe("getInviteTokenAndDelete", function () { + it("should return a deleted invite", async function () { + inviteTokenFindOneAndDeleteStub.resolves(mockInviteToken); + const deletedInvite = await getInviteTokenAndDelete(mockUserData.token); + expect(deletedInvite).to.deep.equal(mockInviteToken); + }); + + it("should handle a token not found", async function () { + inviteTokenFindOneAndDeleteStub.resolves(null); + try { + await getInviteTokenAndDelete(mockUserData.token); + } catch (error) { + expect(error.message).to.equal(errorMessages.AUTH_INVITE_NOT_FOUND); + } + }); + + it("should handle DB errors", async function () { + const err = new Error("test error"); + inviteTokenFindOneAndDeleteStub.rejects(err); + try { + await getInviteTokenAndDelete(mockUserData.token); + } catch (error) { + expect(error).to.deep.equal(err); + expect(error.method).to.equal("getInviteTokenAndDelete"); + } + }); + }); +}); diff --git a/server/tests/db/maintenanceWindowModule.test.js b/server/tests/db/maintenanceWindowModule.test.js new file mode 100755 index 000000000..842c283c8 --- /dev/null +++ b/server/tests/db/maintenanceWindowModule.test.js @@ -0,0 +1,283 @@ +import sinon from "sinon"; +import MaintenanceWindow from "../../db/models/MaintenanceWindow.js"; +import { + createMaintenanceWindow, + getMaintenanceWindowById, + getMaintenanceWindowsByTeamId, + getMaintenanceWindowsByMonitorId, + deleteMaintenanceWindowById, + deleteMaintenanceWindowByMonitorId, + deleteMaintenanceWindowByUserId, + editMaintenanceWindowById, +} from "../../db/mongo/modules/maintenanceWindowModule.js"; + +describe("MaintenanceWindow Module", function () { + const mockMaintenanceWindow = { + monitorId: "123", + active: true, + oneTime: true, + start: 1, + end: 20000, + }; + + let mockMaintenanceWindows = [mockMaintenanceWindow]; + let maintenanceWindowSaveStub, + maintenanceWindowFindByIdStub, + maintenanceWindowCountDocumentsStub, + maintenanceWindowFindStub, + maintenanceWindowFindByIdAndDeleteStub, + maintenanceWindowDeleteManyStub, + maintenanceWindowFindByIdAndUpdateStub; + + beforeEach(function () { + maintenanceWindowSaveStub = sinon.stub(MaintenanceWindow.prototype, "save"); + maintenanceWindowFindByIdStub = sinon.stub(MaintenanceWindow, "findById"); + maintenanceWindowCountDocumentsStub = sinon.stub(MaintenanceWindow, "countDocuments"); + maintenanceWindowFindStub = sinon.stub(MaintenanceWindow, "find").returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns(mockMaintenanceWindows), + }), + }), + }); + maintenanceWindowFindByIdAndDeleteStub = sinon.stub( + MaintenanceWindow, + "findByIdAndDelete" + ); + maintenanceWindowDeleteManyStub = sinon.stub(MaintenanceWindow, "deleteMany"); + maintenanceWindowFindByIdAndUpdateStub = sinon.stub( + MaintenanceWindow, + "findByIdAndUpdate" + ); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("createMaintenanceWindow", function () { + it("should save a new maintenance window", async function () { + maintenanceWindowSaveStub.resolves(mockMaintenanceWindow); + const result = await createMaintenanceWindow(mockMaintenanceWindow); + expect(result).to.deep.equal(mockMaintenanceWindow); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + maintenanceWindowSaveStub.rejects(err); + try { + await createMaintenanceWindow(mockMaintenanceWindow); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getMaintenanceWindowById", function () { + it("should return a maintenance window", async function () { + maintenanceWindowFindByIdStub.resolves(mockMaintenanceWindow); + const result = await getMaintenanceWindowById(mockMaintenanceWindow.id); + expect(result).to.deep.equal(mockMaintenanceWindow); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + maintenanceWindowFindByIdStub.rejects(err); + try { + await getMaintenanceWindowById(mockMaintenanceWindow.id); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getMaintenanceWindowsByTeamId", function () { + let query; + + beforeEach(function () { + query = { + active: true, + page: 1, + rowsPerPage: 10, + field: "name", + order: "asc", + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return a list of maintenance windows and count", async function () { + maintenanceWindowCountDocumentsStub.resolves(1); + const result = await getMaintenanceWindowsByTeamId( + mockMaintenanceWindow.teamId, + query + ); + expect(result).to.deep.equal({ + maintenanceWindows: mockMaintenanceWindows, + maintenanceWindowCount: 1, + }); + }); + + it("should return a list of maintenance windows and count with empty query", async function () { + query = undefined; + maintenanceWindowCountDocumentsStub.resolves(1); + const result = await getMaintenanceWindowsByTeamId( + mockMaintenanceWindow.teamId, + query + ); + expect(result).to.deep.equal({ + maintenanceWindows: mockMaintenanceWindows, + maintenanceWindowCount: 1, + }); + }); + + it("should return a list of maintenance windows and count with no pagination provided", async function () { + query.page = undefined; + query.rowsPerPage = undefined; + maintenanceWindowCountDocumentsStub.resolves(1); + const result = await getMaintenanceWindowsByTeamId( + mockMaintenanceWindow.teamId, + query + ); + expect(result).to.deep.equal({ + maintenanceWindows: mockMaintenanceWindows, + maintenanceWindowCount: 1, + }); + }); + + it("should return a list of maintenance windows and count with field and desc order", async function () { + query.order = "desc"; + maintenanceWindowCountDocumentsStub.resolves(1); + const result = await getMaintenanceWindowsByTeamId( + mockMaintenanceWindow.teamId, + query + ); + expect(result).to.deep.equal({ + maintenanceWindows: mockMaintenanceWindows, + maintenanceWindowCount: 1, + }); + }); + + it("should return a list of maintenance windows and count no field", async function () { + query.field = undefined; + maintenanceWindowCountDocumentsStub.resolves(1); + const result = await getMaintenanceWindowsByTeamId( + mockMaintenanceWindow.teamId, + query + ); + expect(result).to.deep.equal({ + maintenanceWindows: mockMaintenanceWindows, + maintenanceWindowCount: 1, + }); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + maintenanceWindowCountDocumentsStub.rejects(err); + try { + await getMaintenanceWindowsByTeamId(mockMaintenanceWindow.teamId, query); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getMaintenanceWindowsByMonitorId", function () { + it("should return a list of maintenance windows", async function () { + maintenanceWindowFindStub.resolves(mockMaintenanceWindows); + const result = await getMaintenanceWindowsByMonitorId( + mockMaintenanceWindow.monitorId + ); + expect(result).to.deep.equal(mockMaintenanceWindows); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + maintenanceWindowFindStub.rejects(err); + try { + await getMaintenanceWindowsByMonitorId(mockMaintenanceWindow.monitorId); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deleteMaintenanceWindowById", function () { + it("should delete a maintenance window", async function () { + maintenanceWindowFindByIdAndDeleteStub.resolves(mockMaintenanceWindow); + const result = await deleteMaintenanceWindowById(mockMaintenanceWindow.id); + expect(result).to.deep.equal(mockMaintenanceWindow); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + maintenanceWindowFindByIdAndDeleteStub.rejects(err); + try { + await deleteMaintenanceWindowById(mockMaintenanceWindow.id); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deleteMaintenanceWindowByMonitorId", function () { + it("should return the number of documents deleted", async function () { + maintenanceWindowDeleteManyStub.resolves({ deletedCount: 1 }); + const result = await deleteMaintenanceWindowByMonitorId( + mockMaintenanceWindow.monitorId + ); + expect(result).to.deep.equal({ deletedCount: 1 }); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + maintenanceWindowDeleteManyStub.rejects(err); + try { + await deleteMaintenanceWindowByMonitorId(mockMaintenanceWindow.monitorId); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deleteMaintenanceWindowByUserId", function () { + it("should return the number of documents deleted", async function () { + maintenanceWindowDeleteManyStub.resolves({ deletedCount: 1 }); + const result = await deleteMaintenanceWindowByUserId(mockMaintenanceWindow.userId); + expect(result).to.deep.equal({ deletedCount: 1 }); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + maintenanceWindowDeleteManyStub.rejects(err); + try { + await deleteMaintenanceWindowByUserId(mockMaintenanceWindow.userId); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("editMaintenanceWindowById", function () { + it("should return the updated maintenance window", async function () { + maintenanceWindowFindByIdAndUpdateStub.resolves(mockMaintenanceWindow); + const result = await editMaintenanceWindowById( + mockMaintenanceWindow.id, + mockMaintenanceWindow + ); + expect(result).to.deep.equal(mockMaintenanceWindow); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + maintenanceWindowFindByIdAndUpdateStub.rejects(err); + try { + await editMaintenanceWindowById(mockMaintenanceWindow.id, mockMaintenanceWindow); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); +}); diff --git a/server/tests/db/monitorModule.test.js b/server/tests/db/monitorModule.test.js new file mode 100755 index 000000000..3d9bb5324 --- /dev/null +++ b/server/tests/db/monitorModule.test.js @@ -0,0 +1,1931 @@ +import sinon from "sinon"; +import Monitor from "../../db/models/Monitor.js"; +import Check from "../../db/models/Check.js"; +import PageSpeedCheck from "../../db/models/PageSpeedCheck.js"; +import HardwareCheck from "../../db/models/HardwareCheck.js"; +import Notification from "../../db/models/Notification.js"; + +import { errorMessages } from "../../utils/messages.js"; +import { + getAllMonitors, + getAllMonitorsWithUptimeStats, + getMonitorStatsById, + getMonitorById, + getMonitorsAndSummaryByTeamId, + getMonitorsByTeamId, + createMonitor, + deleteMonitor, + deleteAllMonitors, + deleteMonitorsByUserId, + editMonitor, + addDemoMonitors, + calculateUptimeDuration, + getLastChecked, + getLatestResponseTime, + getAverageResponseTime, + getUptimePercentage, + getIncidents, + getMonitorChecks, + processChecksForDisplay, + groupChecksByTime, + calculateGroupStats, +} from "../../db/mongo/modules/monitorModule.js"; + +describe("monitorModule", function () { + let monitorFindStub, + monitorFindByIdStub, + monitorFindByIdAndUpdateStub, + monitorFindByIdAndDeleteStub, + monitorDeleteManyStub, + monitorCountStub, + monitorInsertManyStub, + checkFindStub, + pageSpeedCheckFindStub, + hardwareCheckFindStub; + + beforeEach(function () { + monitorFindStub = sinon.stub(Monitor, "find"); + monitorFindByIdStub = sinon.stub(Monitor, "findById"); + monitorFindByIdAndUpdateStub = sinon.stub(Monitor, "findByIdAndUpdate"); + monitorFindByIdAndDeleteStub = sinon.stub(Monitor, "findByIdAndDelete"); + monitorDeleteManyStub = sinon.stub(Monitor, "deleteMany"); + monitorCountStub = sinon.stub(Monitor, "countDocuments"); + + monitorInsertManyStub = sinon.stub(Monitor, "insertMany"); + checkFindStub = sinon.stub(Check, "find").returns({ + sort: sinon.stub(), + }); + pageSpeedCheckFindStub = sinon.stub(PageSpeedCheck, "find").returns({ + sort: sinon.stub(), + }); + hardwareCheckFindStub = sinon.stub(HardwareCheck, "find").returns({ + sort: sinon.stub(), + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("getAllMonitors", function () { + it("should return all monitors", async function () { + const mockMonitors = [ + { _id: "1", name: "Monitor 1", url: "test1.com" }, + { _id: "2", name: "Monitor 2", url: "test2.com" }, + ]; + monitorFindStub.returns(mockMonitors); + const result = await getAllMonitors(); + + expect(result).to.deep.equal(mockMonitors); + expect(monitorFindStub.calledOnce).to.be.true; + expect(monitorFindStub.firstCall.args).to.deep.equal([]); + }); + + it("should handle empty results", async function () { + monitorFindStub.returns([]); + const result = await getAllMonitors(); + expect(result).to.be.an("array").that.is.empty; + }); + + it("should throw error when database fails", async function () { + // Arrange + const error = new Error("Database error"); + error.service = "MonitorModule"; + error.method = "getAllMonitors"; + monitorFindStub.rejects(error); + // Act & Assert + try { + await getAllMonitors(); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err).to.equal(error); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("getAllMonitors"); + } + }); + }); + + describe("getAllMonitorsWithUptimeStats", function () { + it("should return monitors with uptime stats for different time periods", async function () { + // Mock data + const mockMonitors = [ + { + _id: "monitor1", + type: "http", + toObject: () => ({ + _id: "monitor1", + type: "http", + name: "Test Monitor", + }), + }, + ]; + + const mockChecks = [ + { status: true }, + { status: true }, + { status: false }, + { status: true }, + ]; + + monitorFindStub.resolves(mockMonitors); + checkFindStub.resolves(mockChecks); + + const result = await getAllMonitorsWithUptimeStats(); + + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(1); + + const monitor = result[0]; + expect(monitor).to.have.property("_id", "monitor1"); + expect(monitor).to.have.property("name", "Test Monitor"); + + // Check uptime percentages exist for all time periods + expect(monitor).to.have.property("1"); + expect(monitor).to.have.property("7"); + expect(monitor).to.have.property("30"); + expect(monitor).to.have.property("90"); + + // Verify uptime percentage calculation (3 successful out of 4 = 75%) + expect(monitor["1"]).to.equal(75); + expect(monitor["7"]).to.equal(75); + expect(monitor["30"]).to.equal(75); + expect(monitor["90"]).to.equal(75); + }); + + it("should return monitors with stats for pagespeed type", async function () { + // Mock data + const mockMonitors = [ + { + _id: "monitor1", + type: "pagespeed", + toObject: () => ({ + _id: "monitor1", + type: "pagespeed", + name: "Test Monitor", + }), + }, + ]; + + const mockChecks = [ + { status: true }, + { status: true }, + { status: false }, + { status: true }, + ]; + + monitorFindStub.resolves(mockMonitors); + pageSpeedCheckFindStub.resolves(mockChecks); + + const result = await getAllMonitorsWithUptimeStats(); + + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(1); + + const monitor = result[0]; + expect(monitor).to.have.property("_id", "monitor1"); + expect(monitor).to.have.property("name", "Test Monitor"); + + // Check uptime percentages exist for all time periods + expect(monitor).to.have.property("1"); + expect(monitor).to.have.property("7"); + expect(monitor).to.have.property("30"); + expect(monitor).to.have.property("90"); + + // Verify uptime percentage calculation (3 successful out of 4 = 75%) + expect(monitor["1"]).to.equal(75); + expect(monitor["7"]).to.equal(75); + expect(monitor["30"]).to.equal(75); + expect(monitor["90"]).to.equal(75); + }); + + it("should return monitors with stats for hardware type", async function () { + // Mock data + const mockMonitors = [ + { + _id: "monitor1", + type: "hardware", + toObject: () => ({ + _id: "monitor1", + type: "hardware", + name: "Test Monitor", + }), + }, + ]; + + const mockChecks = [ + { status: true }, + { status: true }, + { status: false }, + { status: true }, + ]; + + monitorFindStub.resolves(mockMonitors); + hardwareCheckFindStub.resolves(mockChecks); + + const result = await getAllMonitorsWithUptimeStats(); + + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(1); + + const monitor = result[0]; + expect(monitor).to.have.property("_id", "monitor1"); + expect(monitor).to.have.property("name", "Test Monitor"); + + // Check uptime percentages exist for all time periods + expect(monitor).to.have.property("1"); + expect(monitor).to.have.property("7"); + expect(monitor).to.have.property("30"); + expect(monitor).to.have.property("90"); + + // Verify uptime percentage calculation (3 successful out of 4 = 75%) + expect(monitor["1"]).to.equal(75); + expect(monitor["7"]).to.equal(75); + expect(monitor["30"]).to.equal(75); + expect(monitor["90"]).to.equal(75); + }); + + it("should handle errors appropriately", async function () { + // Setup stub to throw error + monitorFindStub.rejects(new Error("Database error")); + + try { + await getAllMonitorsWithUptimeStats(); + } catch (error) { + expect(error).to.be.an("error"); + expect(error.message).to.equal("Database error"); + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getAllMonitorsWithUptimeStats"); + } + }); + + it("should handle empty monitor list", async function () { + monitorFindStub.resolves([]); + + const result = await getAllMonitorsWithUptimeStats(); + + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(0); + }); + + it("should handle monitor with no checks", async function () { + const mockMonitors = [ + { + _id: "monitor1", + type: "http", + toObject: () => ({ + _id: "monitor1", + type: "http", + name: "Test Monitor", + }), + }, + ]; + + monitorFindStub.resolves(mockMonitors); + checkFindStub.resolves([]); + + const result = await getAllMonitorsWithUptimeStats(); + + expect(result[0]).to.have.property("1", 0); + expect(result[0]).to.have.property("7", 0); + expect(result[0]).to.have.property("30", 0); + expect(result[0]).to.have.property("90", 0); + }); + }); + + describe("calculateUptimeDuration", function () { + let clock; + const NOW = new Date("2024-01-01T12:00:00Z").getTime(); + + beforeEach(function () { + // Fix the current time + clock = sinon.useFakeTimers(NOW); + }); + + afterEach(function () { + clock.restore(); + }); + + it("should return 0 when checks array is empty", function () { + expect(calculateUptimeDuration([])).to.equal(0); + }); + + it("should return 0 when checks array is null", function () { + expect(calculateUptimeDuration(null)).to.equal(0); + }); + + it("should calculate uptime from last down check to most recent check", function () { + const checks = [ + { status: true, createdAt: "2024-01-01T11:00:00Z" }, // Most recent + { status: true, createdAt: "2024-01-01T10:00:00Z" }, + { status: false, createdAt: "2024-01-01T09:00:00Z" }, // Last down + { status: true, createdAt: "2024-01-01T08:00:00Z" }, + ]; + + // Expected: 2 hours (from 09:00 to 11:00) = 7200000ms + expect(calculateUptimeDuration(checks)).to.equal(7200000); + }); + + it("should calculate uptime from first check when no down checks exist", function () { + const checks = [ + { status: true, createdAt: "2024-01-01T11:00:00Z" }, + { status: true, createdAt: "2024-01-01T10:00:00Z" }, + { status: true, createdAt: "2024-01-01T09:00:00Z" }, + ]; + + // Expected: Current time (12:00) - First check (09:00) = 3 hours = 10800000ms + expect(calculateUptimeDuration(checks)).to.equal(10800000); + }); + }); + + describe("getLastChecked", function () { + let clock; + const NOW = new Date("2024-01-01T12:00:00Z").getTime(); + + beforeEach(function () { + // Fix the current time + clock = sinon.useFakeTimers(NOW); + }); + + afterEach(function () { + clock.restore(); + }); + + it("should return 0 when checks array is empty", function () { + expect(getLastChecked([])).to.equal(0); + }); + + it("should return 0 when checks array is null", function () { + expect(getLastChecked(null)).to.equal(0); + }); + + it("should return time difference between now and most recent check", function () { + const checks = [ + { createdAt: "2024-01-01T11:30:00Z" }, // 30 minutes ago + { createdAt: "2024-01-01T11:00:00Z" }, + { createdAt: "2024-01-01T10:30:00Z" }, + ]; + + // Expected: 30 minutes = 1800000ms + expect(getLastChecked(checks)).to.equal(1800000); + }); + + it("should handle checks from different days", function () { + const checks = [ + { createdAt: "2023-12-31T12:00:00Z" }, // 24 hours ago + { createdAt: "2023-12-30T12:00:00Z" }, + ]; + + // Expected: 24 hours = 86400000ms + expect(getLastChecked(checks)).to.equal(86400000); + }); + }); + + describe("getLatestResponseTime", function () { + it("should return 0 when checks array is empty", function () { + expect(getLatestResponseTime([])).to.equal(0); + }); + + it("should return 0 when checks array is null", function () { + expect(getLatestResponseTime(null)).to.equal(0); + }); + + it("should return response time from most recent check", function () { + const checks = [ + { responseTime: 150, createdAt: "2024-01-01T11:30:00Z" }, // Most recent + { responseTime: 200, createdAt: "2024-01-01T11:00:00Z" }, + { responseTime: 250, createdAt: "2024-01-01T10:30:00Z" }, + ]; + + expect(getLatestResponseTime(checks)).to.equal(150); + }); + + it("should handle missing responseTime in checks", function () { + const checks = [ + { createdAt: "2024-01-01T11:30:00Z" }, + { responseTime: 200, createdAt: "2024-01-01T11:00:00Z" }, + ]; + + expect(getLatestResponseTime(checks)).to.equal(0); + }); + }); + + describe("getAverageResponseTime", function () { + it("should return 0 when checks array is empty", function () { + expect(getAverageResponseTime([])).to.equal(0); + }); + + it("should return 0 when checks array is null", function () { + expect(getAverageResponseTime(null)).to.equal(0); + }); + + it("should calculate average response time from all checks", function () { + const checks = [ + { responseTime: 100, createdAt: "2024-01-01T11:30:00Z" }, + { responseTime: 200, createdAt: "2024-01-01T11:00:00Z" }, + { responseTime: 300, createdAt: "2024-01-01T10:30:00Z" }, + ]; + + // Average: (100 + 200 + 300) / 3 = 200 + expect(getAverageResponseTime(checks)).to.equal(200); + }); + + it("should handle missing responseTime in some checks", function () { + const checks = [ + { responseTime: 100, createdAt: "2024-01-01T11:30:00Z" }, + { createdAt: "2024-01-01T11:00:00Z" }, + { responseTime: 300, createdAt: "2024-01-01T10:30:00Z" }, + ]; + + // Average: (100 + 300) / 2 = 200 + expect(getAverageResponseTime(checks)).to.equal(200); + }); + + it("should return 0 when no checks have responseTime", function () { + const checks = [ + { createdAt: "2024-01-01T11:30:00Z" }, + { createdAt: "2024-01-01T11:00:00Z" }, + ]; + + expect(getAverageResponseTime(checks)).to.equal(0); + }); + }); + + describe("getUptimePercentage", function () { + it("should return 0 when checks array is empty", function () { + expect(getUptimePercentage([])).to.equal(0); + }); + + it("should return 0 when checks array is null", function () { + expect(getUptimePercentage(null)).to.equal(0); + }); + + it("should return 100 when all checks are up", function () { + const checks = [{ status: true }, { status: true }, { status: true }]; + expect(getUptimePercentage(checks)).to.equal(100); + }); + + it("should return 0 when all checks are down", function () { + const checks = [{ status: false }, { status: false }, { status: false }]; + expect(getUptimePercentage(checks)).to.equal(0); + }); + + it("should calculate correct percentage for mixed status checks", function () { + const checks = [ + { status: true }, + { status: false }, + { status: true }, + { status: true }, + ]; + // 3 up out of 4 total = 75% + expect(getUptimePercentage(checks)).to.equal(75); + }); + + it("should handle undefined status values", function () { + const checks = [{ status: true }, { status: undefined }, { status: true }]; + // 2 up out of 3 total ≈ 66.67% + expect(getUptimePercentage(checks)).to.equal((2 / 3) * 100); + }); + }); + + describe("getIncidents", function () { + it("should return 0 when checks array is empty", function () { + expect(getIncidents([])).to.equal(0); + }); + + it("should return 0 when checks array is null", function () { + expect(getIncidents(null)).to.equal(0); + }); + + it("should return 0 when all checks are up", function () { + const checks = [{ status: true }, { status: true }, { status: true }]; + expect(getIncidents(checks)).to.equal(0); + }); + + it("should count all incidents when all checks are down", function () { + const checks = [{ status: false }, { status: false }, { status: false }]; + expect(getIncidents(checks)).to.equal(3); + }); + + it("should count correct number of incidents for mixed status checks", function () { + const checks = [ + { status: true }, + { status: false }, + { status: true }, + { status: false }, + { status: true }, + ]; + expect(getIncidents(checks)).to.equal(2); + }); + + it("should handle undefined status values", function () { + const checks = [ + { status: true }, + { status: undefined }, + { status: false }, + { status: false }, + ]; + // Only counts explicit false values + expect(getIncidents(checks)).to.equal(2); + }); + }); + + describe("getMonitorChecks", function () { + let mockModel; + + beforeEach(function () { + // Create a mock model with chainable methods + const mockChecks = [ + { monitorId: "123", createdAt: new Date("2024-01-01") }, + { monitorId: "123", createdAt: new Date("2024-01-02") }, + ]; + + mockModel = { + find: sinon.stub().returns({ + sort: sinon.stub().returns(mockChecks), + }), + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return all checks and date-ranged checks", async function () { + // Arrange + const monitorId = "123"; + const dateRange = { + start: new Date("2024-01-01"), + end: new Date("2024-01-02"), + }; + const sortOrder = -1; + + // Act + const result = await getMonitorChecks(monitorId, mockModel, dateRange, sortOrder); + + // Assert + expect(result).to.have.keys(["checksAll", "checksForDateRange"]); + + // Verify find was called with correct parameters + expect(mockModel.find.firstCall.args[0]).to.deep.equal({ monitorId }); + expect(mockModel.find.secondCall.args[0]).to.deep.equal({ + monitorId, + createdAt: { $gte: dateRange.start, $lte: dateRange.end }, + }); + + // Verify sort was called with correct parameters + const sortCalls = mockModel.find().sort.getCalls(); + sortCalls.forEach((call) => { + expect(call.args[0]).to.deep.equal({ createdAt: sortOrder }); + }); + }); + + it("should handle empty results", async function () { + // Arrange + const emptyModel = { + find: sinon.stub().returns({ + sort: sinon.stub().returns([]), + }), + }; + + // Act + const result = await getMonitorChecks( + "123", + emptyModel, + { + start: new Date(), + end: new Date(), + }, + -1 + ); + + // Assert + expect(result.checksAll).to.be.an("array").that.is.empty; + expect(result.checksForDateRange).to.be.an("array").that.is.empty; + }); + + it("should maintain sort order", async function () { + // Arrange + const sortedChecks = [ + { monitorId: "123", createdAt: new Date("2024-01-02") }, + { monitorId: "123", createdAt: new Date("2024-01-01") }, + ]; + + const sortedModel = { + find: sinon.stub().returns({ + sort: sinon.stub().returns(sortedChecks), + }), + }; + + // Act + const result = await getMonitorChecks( + "123", + sortedModel, + { + start: new Date("2024-01-01"), + end: new Date("2024-01-02"), + }, + -1 + ); + + // Assert + expect(result.checksAll[0].createdAt).to.be.greaterThan( + result.checksAll[1].createdAt + ); + expect(result.checksForDateRange[0].createdAt).to.be.greaterThan( + result.checksForDateRange[1].createdAt + ); + }); + }); + + describe("processChecksForDisplay", function () { + let normalizeStub; + + beforeEach(function () { + normalizeStub = sinon.stub(); + }); + + it("should return original checks when numToDisplay is not provided", function () { + const checks = [1, 2, 3, 4, 5]; + const result = processChecksForDisplay(normalizeStub, checks); + expect(result).to.deep.equal(checks); + }); + + it("should return original checks when numToDisplay is greater than checks length", function () { + const checks = [1, 2, 3]; + const result = processChecksForDisplay(normalizeStub, checks, 5); + expect(result).to.deep.equal(checks); + }); + + it("should filter checks based on numToDisplay", function () { + const checks = [1, 2, 3, 4, 5, 6]; + const result = processChecksForDisplay(normalizeStub, checks, 3); + // Should return [1, 3, 5] as n = ceil(6/3) = 2 + expect(result).to.deep.equal([1, 3, 5]); + }); + + it("should handle empty checks array", function () { + const checks = []; + const result = processChecksForDisplay(normalizeStub, checks, 3); + expect(result).to.be.an("array").that.is.empty; + }); + + it("should call normalizeData when normalize is true", function () { + const checks = [1, 2, 3]; + normalizeStub.returns([10, 20, 30]); + + const result = processChecksForDisplay(normalizeStub, checks, null, true); + + expect(normalizeStub.args[0]).to.deep.equal([checks, 1, 100]); + expect(result).to.deep.equal([10, 20, 30]); + }); + + it("should handle both filtering and normalization", function () { + const checks = [1, 2, 3, 4, 5, 6]; + normalizeStub.returns([10, 30, 50]); + + const result = processChecksForDisplay(normalizeStub, checks, 3, true); + + expect(normalizeStub.args[0][0]).to.deep.equal([1, 3, 5]); + expect(result).to.deep.equal([10, 30, 50]); + }); + }); + + describe("groupChecksByTime", function () { + const mockChecks = [ + { createdAt: "2024-01-15T10:30:45Z" }, + { createdAt: "2024-01-15T10:45:15Z" }, + { createdAt: "2024-01-15T11:15:00Z" }, + { createdAt: "2024-01-16T10:30:00Z" }, + ]; + + it("should group checks by hour when dateRange is 'day'", function () { + const result = groupChecksByTime(mockChecks, "day"); + + // Get timestamps for 10:00 and 11:00 on Jan 15 + const time1 = new Date("2024-01-15T10:00:00Z").getTime(); + const time2 = new Date("2024-01-15T11:00:00Z").getTime(); + const time3 = new Date("2024-01-16T10:00:00Z").getTime(); + + expect(Object.keys(result)).to.have.lengthOf(3); + + expect(result[time1].checks).to.have.lengthOf(2); + expect(result[time2].checks).to.have.lengthOf(1); + expect(result[time3].checks).to.have.lengthOf(1); + }); + + it("should group checks by day when dateRange is not 'day'", function () { + const result = groupChecksByTime(mockChecks, "week"); + + expect(Object.keys(result)).to.have.lengthOf(2); + expect(result["2024-01-15"].checks).to.have.lengthOf(3); + expect(result["2024-01-16"].checks).to.have.lengthOf(1); + }); + + it("should handle empty checks array", function () { + const result = groupChecksByTime([], "day"); + expect(result).to.deep.equal({}); + }); + + it("should handle single check", function () { + const singleCheck = [{ createdAt: "2024-01-15T10:30:45Z" }]; + const result = groupChecksByTime(singleCheck, "day"); + + const expectedTime = new Date("2024-01-15T10:00:00Z").getTime(); + expect(Object.keys(result)).to.have.lengthOf(1); + expect(result[expectedTime].checks).to.have.lengthOf(1); + }); + + it("should skip invalid dates and process valid ones", function () { + const checksWithInvalidDate = [ + { createdAt: "invalid-date" }, + { createdAt: "2024-01-15T10:30:45Z" }, + { createdAt: null }, + { createdAt: undefined }, + { createdAt: "" }, + ]; + + const result = groupChecksByTime(checksWithInvalidDate, "day"); + + const expectedTime = new Date("2024-01-15T10:00:00Z").getTime(); + expect(Object.keys(result)).to.have.lengthOf(1); + expect(result[expectedTime].checks).to.have.lengthOf(1); + expect(result[expectedTime].checks[0].createdAt).to.equal("2024-01-15T10:30:45Z"); + }); + + it("should handle checks in same time group", function () { + const checksInSameHour = [ + { createdAt: "2024-01-15T10:15:00Z" }, + { createdAt: "2024-01-15T10:45:00Z" }, + ]; + + const result = groupChecksByTime(checksInSameHour, "day"); + + const expectedTime = new Date("2024-01-15T10:00:00Z").getTime(); + expect(Object.keys(result)).to.have.lengthOf(1); + expect(result[expectedTime].checks).to.have.lengthOf(2); + }); + }); + + describe("calculateGroupStats", function () { + // Mock getUptimePercentage function + let uptimePercentageStub; + + beforeEach(function () { + uptimePercentageStub = sinon.stub(); + uptimePercentageStub.returns(95); // Default return value + }); + + it("should calculate stats correctly for a group of checks", function () { + const mockGroup = { + time: "2024-01-15", + checks: [ + { status: true, responseTime: 100 }, + { status: false, responseTime: 200 }, + { status: true, responseTime: 300 }, + ], + }; + + const result = calculateGroupStats(mockGroup, uptimePercentageStub); + + expect(result).to.deep.equal({ + time: "2024-01-15", + uptimePercentage: (2 / 3) * 100, + totalChecks: 3, + totalIncidents: 1, + avgResponseTime: 200, // (100 + 200 + 300) / 3 + }); + }); + + it("should handle empty checks array", function () { + const mockGroup = { + time: "2024-01-15", + checks: [], + }; + + const result = calculateGroupStats(mockGroup, uptimePercentageStub); + + expect(result).to.deep.equal({ + time: "2024-01-15", + uptimePercentage: 0, + totalChecks: 0, + totalIncidents: 0, + avgResponseTime: 0, + }); + }); + + it("should handle missing responseTime values", function () { + const mockGroup = { + time: "2024-01-15", + checks: [ + { status: true }, + { status: false, responseTime: 200 }, + { status: true, responseTime: undefined }, + ], + }; + + const result = calculateGroupStats(mockGroup, uptimePercentageStub); + + expect(result).to.deep.equal({ + time: "2024-01-15", + uptimePercentage: (2 / 3) * 100, + totalChecks: 3, + totalIncidents: 1, + avgResponseTime: 200, // 200 / 1 + }); + }); + + it("should handle all checks with status false", function () { + const mockGroup = { + time: "2024-01-15", + checks: [ + { status: false, responseTime: 100 }, + { status: false, responseTime: 200 }, + { status: false, responseTime: 300 }, + ], + }; + + const result = calculateGroupStats(mockGroup, uptimePercentageStub); + + expect(result).to.deep.equal({ + time: "2024-01-15", + uptimePercentage: 0, + totalChecks: 3, + totalIncidents: 3, + avgResponseTime: 200, + }); + }); + + it("should handle all checks with status true", function () { + const mockGroup = { + time: "2024-01-15", + checks: [ + { status: true, responseTime: 100 }, + { status: true, responseTime: 200 }, + { status: true, responseTime: 300 }, + ], + }; + + const result = calculateGroupStats(mockGroup, uptimePercentageStub); + + expect(result).to.deep.equal({ + time: "2024-01-15", + uptimePercentage: 100, + totalChecks: 3, + totalIncidents: 0, + avgResponseTime: 200, + }); + }); + }); + + describe("getMonitorStatsById", function () { + const now = new Date(); + const oneHourAgo = new Date(now - 3600000); + const twoHoursAgo = new Date(now - 7200000); + + const mockMonitor = { + _id: "monitor123", + type: "http", + name: "Test Monitor", + url: "https://test.com", + toObject: () => ({ + _id: "monitor123", + type: "http", + name: "Test Monitor", + url: "https://test.com", + }), + }; + + const mockMonitorPing = { + _id: "monitor123", + type: "ping", + name: "Test Monitor", + url: "https://test.com", + toObject: () => ({ + _id: "monitor123", + type: "http", + name: "Test Monitor", + url: "https://test.com", + }), + }; + const mockMonitorDocker = { + _id: "monitor123", + type: "docker", + name: "Test Monitor", + url: "https://test.com", + toObject: () => ({ + _id: "monitor123", + type: "http", + name: "Test Monitor", + url: "https://test.com", + }), + }; + + const checkDocs = [ + { + monitorId: "monitor123", + status: true, + responseTime: 100, + createdAt: new Date("2024-01-01T12:00:00Z"), + toObject: function () { + return { + monitorId: this.monitorId, + status: this.status, + responseTime: this.responseTime, + createdAt: this.createdAt, + }; + }, + }, + { + monitorId: "monitor123", + status: true, + responseTime: 150, + createdAt: new Date("2024-01-01T11:00:00Z"), + toObject: function () { + return { + monitorId: this.monitorId, + status: this.status, + responseTime: this.responseTime, + createdAt: this.createdAt, + }; + }, + }, + { + monitorId: "monitor123", + status: false, + responseTime: 200, + createdAt: new Date("2024-01-01T10:00:00Z"), + toObject: function () { + return { + monitorId: this.monitorId, + status: this.status, + responseTime: this.responseTime, + createdAt: this.createdAt, + }; + }, + }, + ]; + const req = { + params: { monitorId: "monitor123" }, + query: { + dateRange: "day", + sortOrder: "desc", + numToDisplay: 10, + normalize: true, + }, + }; + + beforeEach(function () { + checkFindStub.returns({ + sort: () => checkDocs, + }); + monitorFindByIdStub.returns(mockMonitor); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return monitor stats with calculated values, sort order desc", async function () { + req.query.sortOrder = "desc"; + const result = await getMonitorStatsById(req); + expect(result).to.include.keys([ + "_id", + "type", + "name", + "url", + "uptimeDuration", + "lastChecked", + "latestResponseTime", + "periodIncidents", + "periodTotalChecks", + "periodAvgResponseTime", + "periodUptime", + "aggregateData", + ]); + expect(result.latestResponseTime).to.equal(100); + expect(result.periodTotalChecks).to.equal(3); + expect(result.periodIncidents).to.equal(1); + expect(result.periodUptime).to.be.a("number"); + expect(result.aggregateData).to.be.an("array"); + }); + + it("should return monitor stats with calculated values, ping type", async function () { + monitorFindByIdStub.returns(mockMonitorPing); + req.query.sortOrder = "desc"; + const result = await getMonitorStatsById(req); + expect(result).to.include.keys([ + "_id", + "type", + "name", + "url", + "uptimeDuration", + "lastChecked", + "latestResponseTime", + "periodIncidents", + "periodTotalChecks", + "periodAvgResponseTime", + "periodUptime", + "aggregateData", + ]); + expect(result.latestResponseTime).to.equal(100); + expect(result.periodTotalChecks).to.equal(3); + expect(result.periodIncidents).to.equal(1); + expect(result.periodUptime).to.be.a("number"); + expect(result.aggregateData).to.be.an("array"); + }); + + it("should return monitor stats with calculated values, docker type", async function () { + monitorFindByIdStub.returns(mockMonitorDocker); + req.query.sortOrder = "desc"; + const result = await getMonitorStatsById(req); + expect(result).to.include.keys([ + "_id", + "type", + "name", + "url", + "uptimeDuration", + "lastChecked", + "latestResponseTime", + "periodIncidents", + "periodTotalChecks", + "periodAvgResponseTime", + "periodUptime", + "aggregateData", + ]); + expect(result.latestResponseTime).to.equal(100); + expect(result.periodTotalChecks).to.equal(3); + expect(result.periodIncidents).to.equal(1); + expect(result.periodUptime).to.be.a("number"); + expect(result.aggregateData).to.be.an("array"); + }); + + it("should return monitor stats with calculated values", async function () { + req.query.sortOrder = "asc"; + const result = await getMonitorStatsById(req); + expect(result).to.include.keys([ + "_id", + "type", + "name", + "url", + "uptimeDuration", + "lastChecked", + "latestResponseTime", + "periodIncidents", + "periodTotalChecks", + "periodAvgResponseTime", + "periodUptime", + "aggregateData", + ]); + expect(result.latestResponseTime).to.equal(100); + expect(result.periodTotalChecks).to.equal(3); + expect(result.periodIncidents).to.equal(1); + expect(result.periodUptime).to.be.a("number"); + expect(result.aggregateData).to.be.an("array"); + }); + + it("should throw error when monitor is not found", async function () { + monitorFindByIdStub.returns(Promise.resolve(null)); + + const req = { + params: { monitorId: "nonexistent" }, + }; + + try { + await getMonitorStatsById(req); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error).to.be.an("Error"); + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getMonitorStatsById"); + } + }); + }); + + describe("getMonitorById", function () { + let notificationFindStub; + let monitorSaveStub; + + beforeEach(function () { + // Create stubs + notificationFindStub = sinon.stub(Notification, "find"); + monitorSaveStub = sinon.stub().resolves(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return monitor with notifications when found", async function () { + // Arrange + const monitorId = "123"; + const mockMonitor = { + _id: monitorId, + name: "Test Monitor", + save: monitorSaveStub, + }; + const mockNotifications = [ + { _id: "notif1", message: "Test notification 1" }, + { _id: "notif2", message: "Test notification 2" }, + ]; + + monitorFindByIdStub.resolves(mockMonitor); + notificationFindStub.resolves(mockNotifications); + + const result = await getMonitorById(monitorId); + expect(result._id).to.equal(monitorId); + expect(result.name).to.equal("Test Monitor"); + expect(monitorFindByIdStub.calledWith(monitorId)).to.be.true; + expect(notificationFindStub.calledWith({ monitorId })).to.be.true; + expect(monitorSaveStub.calledOnce).to.be.true; + }); + + it("should throw 404 error when monitor not found", async function () { + // Arrange + const monitorId = "nonexistent"; + monitorFindByIdStub.resolves(null); + + // Act & Assert + try { + await getMonitorById(monitorId); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error.message).to.equal(errorMessages.DB_FIND_MONITOR_BY_ID(monitorId)); + expect(error.status).to.equal(404); + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getMonitorById"); + } + }); + + it("should handle database errors properly", async function () { + // Arrange + const monitorId = "123"; + const dbError = new Error("Database connection failed"); + monitorFindByIdStub.rejects(dbError); + + // Act & Assert + try { + await getMonitorById(monitorId); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getMonitorById"); + expect(error.message).to.equal("Database connection failed"); + } + }); + + it("should handle notification fetch errors", async function () { + // Arrange + const monitorId = "123"; + const mockMonitor = { + _id: monitorId, + name: "Test Monitor", + save: monitorSaveStub, + }; + const notificationError = new Error("Notification fetch failed"); + + monitorFindByIdStub.resolves(mockMonitor); + notificationFindStub.rejects(notificationError); + + // Act & Assert + try { + await getMonitorById(monitorId); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getMonitorById"); + expect(error.message).to.equal("Notification fetch failed"); + } + }); + + it("should handle monitor save errors", async function () { + // Arrange + const monitorId = "123"; + const mockMonitor = { + _id: monitorId, + name: "Test Monitor", + save: sinon.stub().rejects(new Error("Save failed")), + }; + const mockNotifications = []; + + monitorFindByIdStub.resolves(mockMonitor); + notificationFindStub.resolves(mockNotifications); + + // Act & Assert + try { + await getMonitorById(monitorId); + expect.fail("Should have thrown an error"); + } catch (error) { + expect(error.service).to.equal("monitorModule"); + expect(error.method).to.equal("getMonitorById"); + expect(error.message).to.equal("Save failed"); + } + }); + }); + + describe("getMonitorsAndSummaryByTeamId", function () { + it("should return monitors and correct summary counts", async function () { + // Arrange + const teamId = "team123"; + const type = "http"; + const mockMonitors = [ + { teamId, type, status: true, isActive: true }, // up + { teamId, type, status: false, isActive: true }, // down + { teamId, type, status: null, isActive: false }, // paused + { teamId, type, status: true, isActive: true }, // up + ]; + monitorFindStub.resolves(mockMonitors); + + // Act + const result = await getMonitorsAndSummaryByTeamId(teamId, type); + + // Assert + expect(result.monitors).to.have.lengthOf(4); + expect(result.monitorCounts).to.deep.equal({ + up: 2, + down: 1, + paused: 1, + total: 4, + }); + expect(monitorFindStub.calledOnceWith({ teamId, type })).to.be.true; + }); + + it("should return empty results for non-existent team", async function () { + // Arrange + monitorFindStub.resolves([]); + + // Act + const result = await getMonitorsAndSummaryByTeamId("nonexistent", "http"); + + // Assert + expect(result.monitors).to.have.lengthOf(0); + expect(result.monitorCounts).to.deep.equal({ + up: 0, + down: 0, + paused: 0, + total: 0, + }); + }); + + it("should handle database errors", async function () { + // Arrange + const error = new Error("Database error"); + error.service = "MonitorModule"; + error.method = "getMonitorsAndSummaryByTeamId"; + monitorFindStub.rejects(error); + + // Act & Assert + try { + await getMonitorsAndSummaryByTeamId("team123", "http"); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err).to.equal(error); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("getMonitorsAndSummaryByTeamId"); + } + }); + }); + + describe("getMonitorsByTeamId", function () { + beforeEach(function () { + // Chain stubs for Monitor.find().skip().limit().sort() + + // Stub for CHECK_MODEL_LOOKUP model find + checkFindStub.returns({ + sort: sinon.stub().returns({ + limit: sinon.stub().returns([]), + }), + }); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should return monitors with basic query parameters", async function () { + const mockMonitors = [ + { _id: "1", type: "http", toObject: () => ({ _id: "1", type: "http" }) }, + { _id: "2", type: "ping", toObject: () => ({ _id: "2", type: "ping" }) }, + ]; + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns(mockMonitors), + }), + }), + }); + + const req = { + params: { teamId: "team123" }, + query: { + type: "http", + page: 0, + rowsPerPage: 10, + field: "name", + status: false, + checkOrder: "desc", + }, + }; + + monitorCountStub.resolves(2); + + const result = await getMonitorsByTeamId(req); + + expect(result).to.have.property("monitors"); + expect(result).to.have.property("monitorCount", 2); + }); + + it("should return monitors with basic query parameters", async function () { + const mockMonitors = [ + { _id: "1", type: "http", toObject: () => ({ _id: "1", type: "http" }) }, + { _id: "2", type: "ping", toObject: () => ({ _id: "2", type: "ping" }) }, + ]; + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns(mockMonitors), + }), + }), + }); + + const req = { + params: { teamId: "team123" }, + query: { + type: "http", + page: 0, + rowsPerPage: 10, + field: "name", + status: true, + checkOrder: "asc", + }, + }; + + monitorCountStub.resolves(2); + + const result = await getMonitorsByTeamId(req); + + expect(result).to.have.property("monitors"); + expect(result).to.have.property("monitorCount", 2); + }); + + it("should handle type filter with array input", async function () { + const req = { + params: { teamId: "team123" }, + query: { + type: ["http", "ping"], + }, + }; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns([]), + }), + }), + }); + monitorCountStub.resolves(0); + + await getMonitorsByTeamId(req); + + expect(Monitor.find.firstCall.args[0]).to.deep.equal({ + teamId: "team123", + type: { $in: ["http", "ping"] }, + }); + }); + + it("should handle text search filter", async function () { + const req = { + params: { teamId: "team123" }, + query: { + filter: "search", + }, + }; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns([]), + }), + }), + }); + monitorCountStub.resolves(0); + + await getMonitorsByTeamId(req); + + expect(Monitor.find.firstCall.args[0]).to.deep.equal({ + teamId: "team123", + $or: [ + { name: { $regex: "search", $options: "i" } }, + { url: { $regex: "search", $options: "i" } }, + ], + }); + }); + + it("should handle pagination parameters", async function () { + const req = { + params: { teamId: "team123" }, + query: { + page: 2, + rowsPerPage: 5, + }, + }; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns([]), + }), + }), + }); + monitorCountStub.resolves(0); + + const result = await getMonitorsByTeamId(req); + expect(result).to.deep.equal({ + monitors: [], + monitorCount: 0, + }); + }); + + it("should handle sorting parameters", async function () { + const req = { + params: { teamId: "team123" }, + query: { + field: "name", + order: "asc", + }, + }; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns([]), + }), + }), + }); + monitorCountStub.resolves(0); + + await getMonitorsByTeamId(req); + + const result = await getMonitorsByTeamId(req); + expect(result).to.deep.equal({ + monitors: [], + monitorCount: 0, + }); + }); + + it("should return early when limit is -1", async function () { + // Arrange + const req = { + params: { teamId: "team123" }, + query: { + limit: "-1", + }, + }; + + const mockMonitors = [ + { _id: "1", type: "http" }, + { _id: "2", type: "ping" }, + ]; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns(mockMonitors), + }), + }), + }); + + monitorCountStub.resolves(2); + + // Act + const result = await getMonitorsByTeamId(req); + + // Assert + expect(result).to.deep.equal({ + monitors: mockMonitors, + monitorCount: 2, + }); + }); + + it("should normalize checks when normalize parameter is provided", async function () { + const req = { + params: { teamId: "team123" }, + query: { normalize: "true" }, + }; + monitorCountStub.resolves(2); + + const mockMonitors = [ + { _id: "1", type: "http", toObject: () => ({ _id: "1", type: "http" }) }, + { _id: "2", type: "ping", toObject: () => ({ _id: "2", type: "ping" }) }, + ]; + + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().returns(mockMonitors), + }), + }), + }); + + const result = await getMonitorsByTeamId(req); + expect(result.monitorCount).to.equal(2); + expect(result.monitors).to.have.lengthOf(2); + }); + + it("should handle database errors", async function () { + const req = { + params: { teamId: "team123" }, + query: {}, + }; + + const error = new Error("Database error"); + monitorFindStub.returns({ + skip: sinon.stub().returns({ + limit: sinon.stub().returns({ + sort: sinon.stub().throws(error), + }), + }), + }); + + try { + await getMonitorsByTeamId(req); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("getMonitorsByTeamId"); + expect(err.message).to.equal("Database error"); + } + }); + }); + + describe("createMonitor", function () { + it("should create a monitor without notifications", async function () { + let monitorSaveStub = sinon.stub(Monitor.prototype, "save").resolves(); + + const req = { + body: { + name: "Test Monitor", + url: "http://test.com", + type: "http", + notifications: ["someNotification"], + }, + }; + + const expectedMonitor = { + name: "Test Monitor", + url: "http://test.com", + type: "http", + notifications: undefined, + save: monitorSaveStub, + }; + + const result = await createMonitor(req); + expect(result.name).to.equal(expectedMonitor.name); + expect(result.url).to.equal(expectedMonitor.url); + }); + + it("should handle database errors", async function () { + const req = { + body: { + name: "Test Monitor", + }, + }; + + try { + await createMonitor(req); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("createMonitor"); + } + }); + }); + + describe("deleteMonitor", function () { + it("should delete a monitor successfully", async function () { + const monitorId = "123456789"; + const mockMonitor = { + _id: monitorId, + name: "Test Monitor", + url: "http://test.com", + }; + + const req = { + params: { monitorId }, + }; + + monitorFindByIdAndDeleteStub.resolves(mockMonitor); + + const result = await deleteMonitor(req); + + expect(result).to.deep.equal(mockMonitor); + sinon.assert.calledWith(monitorFindByIdAndDeleteStub, monitorId); + }); + + it("should throw error when monitor not found", async function () { + const monitorId = "nonexistent123"; + const req = { + params: { monitorId }, + }; + + monitorFindByIdAndDeleteStub.resolves(null); + + try { + await deleteMonitor(req); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.message).to.equal(errorMessages.DB_FIND_MONITOR_BY_ID(monitorId)); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("deleteMonitor"); + } + }); + + it("should handle database errors", async function () { + const monitorId = "123456789"; + const req = { + params: { monitorId }, + }; + + const dbError = new Error("Database connection error"); + monitorFindByIdAndDeleteStub.rejects(dbError); + + try { + await deleteMonitor(req); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.message).to.equal("Database connection error"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("deleteMonitor"); + } + }); + }); + + describe("deleteAllMonitors", function () { + it("should delete all monitors for a team successfully", async function () { + const teamId = "team123"; + const mockMonitors = [ + { _id: "1", name: "Monitor 1", teamId }, + { _id: "2", name: "Monitor 2", teamId }, + ]; + + monitorFindStub.resolves(mockMonitors); + monitorDeleteManyStub.resolves({ deletedCount: 2 }); + + const result = await deleteAllMonitors(teamId); + + expect(result).to.deep.equal({ + monitors: mockMonitors, + deletedCount: 2, + }); + sinon.assert.calledWith(monitorFindStub, { teamId }); + sinon.assert.calledWith(monitorDeleteManyStub, { teamId }); + }); + + it("should return empty array when no monitors found", async function () { + const teamId = "emptyTeam"; + + monitorFindStub.resolves([]); + monitorDeleteManyStub.resolves({ deletedCount: 0 }); + + const result = await deleteAllMonitors(teamId); + + expect(result).to.deep.equal({ + monitors: [], + deletedCount: 0, + }); + sinon.assert.calledWith(monitorFindStub, { teamId }); + sinon.assert.calledWith(monitorDeleteManyStub, { teamId }); + }); + + it("should handle database errors", async function () { + const teamId = "team123"; + const dbError = new Error("Database connection error"); + monitorFindStub.rejects(dbError); + + try { + await deleteAllMonitors(teamId); + } catch (err) { + expect(err.message).to.equal("Database connection error"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("deleteAllMonitors"); + } + }); + + it("should handle deleteMany errors", async function () { + const teamId = "team123"; + monitorFindStub.resolves([{ _id: "1", name: "Monitor 1" }]); + monitorDeleteManyStub.rejects(new Error("Delete operation failed")); + + try { + await deleteAllMonitors(teamId); + } catch (err) { + expect(err.message).to.equal("Delete operation failed"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("deleteAllMonitors"); + } + }); + }); + + describe("deleteMonitorsByUserId", function () { + beforeEach(function () {}); + + afterEach(function () { + sinon.restore(); + }); + + it("should delete all monitors for a user successfully", async function () { + // Arrange + const userId = "user123"; + const mockResult = { + deletedCount: 3, + acknowledged: true, + }; + + monitorDeleteManyStub.resolves(mockResult); + + // Act + const result = await deleteMonitorsByUserId(userId); + + // Assert + expect(result).to.deep.equal(mockResult); + sinon.assert.calledWith(monitorDeleteManyStub, { userId: userId }); + }); + + it("should return zero deletedCount when no monitors found", async function () { + // Arrange + const userId = "nonexistentUser"; + const mockResult = { + deletedCount: 0, + acknowledged: true, + }; + + monitorDeleteManyStub.resolves(mockResult); + + // Act + const result = await deleteMonitorsByUserId(userId); + + // Assert + expect(result.deletedCount).to.equal(0); + sinon.assert.calledWith(monitorDeleteManyStub, { userId: userId }); + }); + + it("should handle database errors", async function () { + // Arrange + const userId = "user123"; + const dbError = new Error("Database connection error"); + monitorDeleteManyStub.rejects(dbError); + + // Act & Assert + try { + await deleteMonitorsByUserId(userId); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.message).to.equal("Database connection error"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("deleteMonitorsByUserId"); + } + }); + }); + + describe("editMonitor", function () { + it("should edit a monitor successfully", async function () { + // Arrange + const candidateId = "monitor123"; + const candidateMonitor = { + name: "Updated Monitor", + url: "http://updated.com", + type: "http", + notifications: ["someNotification"], + }; + + const expectedUpdateData = { + name: "Updated Monitor", + url: "http://updated.com", + type: "http", + notifications: undefined, + }; + + const mockUpdatedMonitor = { + _id: candidateId, + ...expectedUpdateData, + }; + + monitorFindByIdAndUpdateStub.resolves(mockUpdatedMonitor); + + // Act + const result = await editMonitor(candidateId, candidateMonitor); + + // Assert + expect(result).to.deep.equal(mockUpdatedMonitor); + sinon.assert.calledWith( + monitorFindByIdAndUpdateStub, + candidateId, + expectedUpdateData, + { + new: true, + } + ); + }); + + it("should return null when monitor not found", async function () { + // Arrange + const candidateId = "nonexistent123"; + const candidateMonitor = { + name: "Updated Monitor", + }; + + monitorFindByIdAndUpdateStub.resolves(null); + + // Act + const result = await editMonitor(candidateId, candidateMonitor); + + // Assert + expect(result).to.be.null; + sinon.assert.calledWith( + monitorFindByIdAndUpdateStub, + candidateId, + { name: "Updated Monitor", notifications: undefined }, + { new: true } + ); + }); + + it("should remove notifications from update data", async function () { + // Arrange + const candidateId = "monitor123"; + const candidateMonitor = { + name: "Updated Monitor", + notifications: ["notification1", "notification2"], + }; + + const expectedUpdateData = { + name: "Updated Monitor", + notifications: undefined, + }; + + monitorFindByIdAndUpdateStub.resolves({ + _id: candidateId, + ...expectedUpdateData, + }); + + // Act + await editMonitor(candidateId, candidateMonitor); + + // Assert + sinon.assert.calledWith( + monitorFindByIdAndUpdateStub, + candidateId, + expectedUpdateData, + { + new: true, + } + ); + }); + + it("should handle database errors", async function () { + // Arrange + const candidateId = "monitor123"; + const candidateMonitor = { + name: "Updated Monitor", + }; + + const dbError = new Error("Database connection error"); + monitorFindByIdAndUpdateStub.rejects(dbError); + + // Act & Assert + try { + await editMonitor(candidateId, candidateMonitor); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err.message).to.equal("Database connection error"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("editMonitor"); + } + }); + }); + + describe("addDemoMonitors", function () { + it("should add demo monitors successfully", async function () { + // Arrange + const userId = "user123"; + const teamId = "team123"; + monitorInsertManyStub.resolves([{ _id: "123" }]); + const result = await addDemoMonitors(userId, teamId); + expect(result).to.deep.equal([{ _id: "123" }]); + }); + + it("should handle database errors", async function () { + const userId = "user123"; + const teamId = "team123"; + + const dbError = new Error("Database connection error"); + monitorInsertManyStub.rejects(dbError); + + try { + await addDemoMonitors(userId, teamId); + } catch (err) { + expect(err.message).to.equal("Database connection error"); + expect(err.service).to.equal("monitorModule"); + expect(err.method).to.equal("addDemoMonitors"); + } + }); + }); +}); diff --git a/server/tests/db/notificationModule.test.js b/server/tests/db/notificationModule.test.js new file mode 100755 index 000000000..3128130f8 --- /dev/null +++ b/server/tests/db/notificationModule.test.js @@ -0,0 +1,80 @@ +import sinon from "sinon"; +import Notification from "../../db/models/Notification.js"; +import { + createNotification, + getNotificationsByMonitorId, + deleteNotificationsByMonitorId, +} from "../../db/mongo/modules/notificationModule.js"; + +describe("notificationModule", function () { + const mockNotification = { + monitorId: "123", + }; + const mockNotifications = [mockNotification]; + let notificationSaveStub, notificationFindStub, notificationDeleteManyStub; + + beforeEach(function () { + notificationSaveStub = sinon.stub(Notification.prototype, "save").resolves(); + notificationFindStub = sinon.stub(Notification, "find").resolves(); + notificationDeleteManyStub = sinon.stub(Notification, "deleteMany").resolves(); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("createNotification", function () { + it("should create a new notification", async function () { + const notificationData = { _id: "123", name: "test" }; + notificationSaveStub.resolves(notificationData); + const res = await createNotification(notificationData); + expect(res).to.deep.equal(notificationData); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + notificationSaveStub.rejects(err); + try { + await createNotification(mockNotification); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getNotificationsByMonitorId", function () { + it("should return notifications by monitor ID", async function () { + notificationFindStub.resolves(mockNotifications); + const res = await getNotificationsByMonitorId(mockNotification.monitorId); + expect(res).to.deep.equal(mockNotifications); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + notificationFindStub.rejects(err); + try { + await getNotificationsByMonitorId(mockNotification.monitorId); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deleteNotificationsByMonitorId", function () { + it("should delete notifications by monitor ID", async function () { + notificationDeleteManyStub.resolves({ deletedCount: mockNotifications.length }); + const res = await deleteNotificationsByMonitorId(mockNotification.monitorId); + expect(res).to.deep.equal(mockNotifications.length); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + notificationDeleteManyStub.rejects(err); + try { + await deleteNotificationsByMonitorId(mockNotification.monitorId); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); +}); diff --git a/server/tests/db/pageSpeedCheckModule.test.js b/server/tests/db/pageSpeedCheckModule.test.js new file mode 100755 index 000000000..5ef0e1845 --- /dev/null +++ b/server/tests/db/pageSpeedCheckModule.test.js @@ -0,0 +1,66 @@ +import sinon from "sinon"; +import PageSpeedCheck from "../../db/models/PageSpeedCheck.js"; +import { + createPageSpeedCheck, + deletePageSpeedChecksByMonitorId, +} from "../../db/mongo/modules/pageSpeedCheckModule.js"; + +const mockPageSpeedCheck = { + monitorId: "monitorId", + bestPractices: 1, + seo: 1, + performance: 1, +}; + +const mockDeletedResult = { deletedCount: 1 }; + +describe("pageSpeedCheckModule", function () { + let pageSpeedCheckSaveStub, pageSpeedCheckDeleteManyStub; + + beforeEach(function () { + pageSpeedCheckSaveStub = sinon.stub(PageSpeedCheck.prototype, "save"); + pageSpeedCheckDeleteManyStub = sinon.stub(PageSpeedCheck, "deleteMany"); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("createPageSpeedCheck", function () { + it("should return a page speed check", async function () { + pageSpeedCheckSaveStub.resolves(mockPageSpeedCheck); + const pageSpeedCheck = await createPageSpeedCheck(mockPageSpeedCheck); + expect(pageSpeedCheck).to.deep.equal(mockPageSpeedCheck); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + pageSpeedCheckSaveStub.rejects(err); + try { + await expect(createPageSpeedCheck(mockPageSpeedCheck)); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deletePageSpeedChecksByMonitorId", function () { + it("should return the number of deleted checks", async function () { + pageSpeedCheckDeleteManyStub.resolves(mockDeletedResult); + const result = await deletePageSpeedChecksByMonitorId("monitorId"); + expect(result).to.deep.equal(mockDeletedResult.deletedCount); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + pageSpeedCheckDeleteManyStub.rejects(err); + try { + await expect(deletePageSpeedChecksByMonitorId("monitorId")); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); +}); diff --git a/server/tests/db/recoveryModule.test.js b/server/tests/db/recoveryModule.test.js new file mode 100755 index 000000000..3f4e11c5c --- /dev/null +++ b/server/tests/db/recoveryModule.test.js @@ -0,0 +1,179 @@ +import sinon from "sinon"; +import RecoveryToken from "../../db/models/RecoveryToken.js"; +import User from "../../db/models/User.js"; +import { + requestRecoveryToken, + validateRecoveryToken, + resetPassword, +} from "../../db/mongo/modules/recoveryModule.js"; +import { errorMessages } from "../../utils/messages.js"; + +const mockRecoveryToken = { + email: "test@test.com", + token: "1234567890", +}; + +const mockUser = { + email: "test@test.com", + password: "oldPassword", +}; + +const mockUserWithoutPassword = { + email: "test@test.com", +}; + +// Create a query builder that logs +const createQueryChain = (finalResult, comparePasswordResult = false) => ({ + select: () => ({ + select: async () => { + if (finalResult === mockUser) { + // Return a new object with all required methods + return { + email: "test@test.com", + password: "oldPassword", + comparePassword: sinon.stub().resolves(comparePasswordResult), + save: sinon.stub().resolves(), + }; + } + return finalResult; + }, + }), + // Add methods to the top level too + comparePassword: sinon.stub().resolves(comparePasswordResult), + save: sinon.stub().resolves(), +}); + +describe("recoveryModule", function () { + let deleteManyStub, + saveStub, + findOneStub, + userCompareStub, + userSaveStub, + userFindOneStub; + let req, res; + + beforeEach(function () { + req = { + body: { email: "test@test.com" }, + }; + deleteManyStub = sinon.stub(RecoveryToken, "deleteMany"); + saveStub = sinon.stub(RecoveryToken.prototype, "save"); + findOneStub = sinon.stub(RecoveryToken, "findOne"); + userCompareStub = sinon.stub(User.prototype, "comparePassword"); + userSaveStub = sinon.stub(User.prototype, "save"); + userFindOneStub = sinon.stub().resolves(); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("requestRecoveryToken", function () { + it("should return a recovery token", async function () { + deleteManyStub.resolves(); + saveStub.resolves(mockRecoveryToken); + const result = await requestRecoveryToken(req, res); + expect(result.email).to.equal(mockRecoveryToken.email); + }); + + it("should handle an error", async function () { + const err = new Error("Test error"); + deleteManyStub.rejects(err); + try { + await requestRecoveryToken(req, res); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); + + describe("validateRecoveryToken", function () { + it("should return a recovery token if found", async function () { + findOneStub.resolves(mockRecoveryToken); + const result = await validateRecoveryToken(req, res); + expect(result).to.deep.equal(mockRecoveryToken); + }); + + it("should thrown an error if a token is not found", async function () { + findOneStub.resolves(null); + try { + await validateRecoveryToken(req, res); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.equal(errorMessages.DB_TOKEN_NOT_FOUND); + } + }); + + it("should handle DB errors", async function () { + const err = new Error("Test error"); + findOneStub.rejects(err); + try { + await validateRecoveryToken(req, res); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); + + describe("resetPassword", function () { + beforeEach(function () { + req.body = { + password: "test", + newPassword: "test1", + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should thrown an error if a recovery token is not found", async function () { + findOneStub.resolves(null); + try { + await resetPassword(req, res); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.equal(errorMessages.DB_TOKEN_NOT_FOUND); + } + }); + + it("should throw an error if a user is not found", async function () { + findOneStub.resolves(mockRecoveryToken); + userFindOneStub = sinon.stub(User, "findOne").resolves(null); + try { + await resetPassword(req, res); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.equal(errorMessages.DB_USER_NOT_FOUND); + } + }); + + it("should throw an error if the passwords match", async function () { + findOneStub.resolves(mockRecoveryToken); + saveStub.resolves(); + userFindOneStub = sinon + .stub(User, "findOne") + .returns(createQueryChain(mockUser, true)); + try { + await resetPassword(req, res); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.equal(errorMessages.DB_RESET_PASSWORD_BAD_MATCH); + } + }); + + it("should return a user without password if successful", async function () { + findOneStub.resolves(mockRecoveryToken); + saveStub.resolves(); + userFindOneStub = sinon + .stub(User, "findOne") + .returns(createQueryChain(mockUser)) // First call will resolve to mockUser + .onSecondCall() + .returns(createQueryChain(mockUserWithoutPassword)); + const result = await resetPassword(req, res); + expect(result).to.deep.equal(mockUserWithoutPassword); + }); + }); +}); diff --git a/server/tests/db/settingsModule.test.js b/server/tests/db/settingsModule.test.js new file mode 100755 index 000000000..7651adb4b --- /dev/null +++ b/server/tests/db/settingsModule.test.js @@ -0,0 +1,59 @@ +import sinon from "sinon"; +import { + getAppSettings, + updateAppSettings, +} from "../../db/mongo/modules/settingsModule.js"; +import AppSettings from "../../db/models/AppSettings.js"; + +const mockAppSettings = { + appName: "Test App", +}; + +describe("SettingsModule", function () { + let appSettingsFindOneStub, appSettingsFindOneAndUpdateStub; + + beforeEach(function () { + appSettingsFindOneStub = sinon.stub(AppSettings, "findOne"); + appSettingsFindOneAndUpdateStub = sinon.stub(AppSettings, "findOneAndUpdate"); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("getAppSettings", function () { + it("should return app settings", async function () { + appSettingsFindOneStub.resolves(mockAppSettings); + const result = await getAppSettings(); + expect(result).to.deep.equal(mockAppSettings); + }); + + it("should handle an error", async function () { + const err = new Error("Test error"); + appSettingsFindOneStub.throws(err); + try { + await getAppSettings(); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); + + describe("updateAppSettings", function () { + it("should update app settings", async function () { + appSettingsFindOneAndUpdateStub.resolves(mockAppSettings); + const result = await updateAppSettings(mockAppSettings); + expect(result).to.deep.equal(mockAppSettings); + }); + + it("should handle an error", async function () { + const err = new Error("Test error"); + appSettingsFindOneAndUpdateStub.throws(err); + try { + await updateAppSettings(mockAppSettings); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + }); +}); diff --git a/server/tests/db/statusPageModule.test.js b/server/tests/db/statusPageModule.test.js new file mode 100755 index 000000000..60c356aa5 --- /dev/null +++ b/server/tests/db/statusPageModule.test.js @@ -0,0 +1,73 @@ +import sinon from "sinon"; +import { + createStatusPage, + getStatusPageByUrl, +} from "../../db/mongo/modules/statusPageModule.js"; +import StatusPage from "../../db/models/StatusPage.js"; +import { errorMessages } from "../../utils/messages.js"; + +describe("statusPageModule", function () { + let statusPageFindOneStub, statusPageSaveStub, statusPageFindStub; + + beforeEach(function () { + statusPageSaveStub = sinon.stub(StatusPage.prototype, "save"); + statusPageFindOneStub = sinon.stub(StatusPage, "findOne"); + statusPageFindStub = sinon.stub(StatusPage, "find"); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("createStatusPage", function () { + it("should throw an error if a non-unique url is provided", async function () { + statusPageFindOneStub.resolves(true); + try { + await createStatusPage({ url: "test" }); + } catch (error) { + expect(error.status).to.equal(400); + expect(error.message).to.equal(errorMessages.STATUS_PAGE_URL_NOT_UNIQUE); + } + }); + + it("should handle duplicate URL errors", async function () { + const err = new Error("test"); + err.code = 11000; + statusPageSaveStub.rejects(err); + try { + await createStatusPage({ url: "test" }); + } catch (error) { + expect(error).to.deep.equal(err); + } + }); + + it("should return a status page if a unique url is provided", async function () { + statusPageFindOneStub.resolves(null); + statusPageFindStub.resolves([]); + const mockStatusPage = { url: "test" }; + const statusPage = await createStatusPage(mockStatusPage); + expect(statusPage).to.exist; + expect(statusPage.url).to.equal(mockStatusPage.url); + }); + }); + + describe("getStatusPageByUrl", function () { + it("should throw an error if a status page is not found", async function () { + statusPageFindOneStub.resolves(null); + try { + await getStatusPageByUrl("test"); + } catch (error) { + expect(error.status).to.equal(404); + expect(error.message).to.equal(errorMessages.STATUS_PAGE_NOT_FOUND); + } + }); + + it("should return a status page if a status page is found", async function () { + const mockStatusPage = { url: "test" }; + statusPageFindOneStub.resolves(mockStatusPage); + const statusPage = await getStatusPageByUrl(mockStatusPage.url); + expect(statusPage).to.exist; + expect(statusPage).to.deep.equal(mockStatusPage); + }); + }); +}); diff --git a/server/tests/db/userModule.test.js b/server/tests/db/userModule.test.js new file mode 100755 index 000000000..884956995 --- /dev/null +++ b/server/tests/db/userModule.test.js @@ -0,0 +1,304 @@ +import sinon from "sinon"; +import UserModel from "../../db/models/User.js"; +import TeamModel from "../../db/models/Team.js"; +import { + insertUser, + getUserByEmail, + updateUser, + deleteUser, + deleteTeam, + deleteAllOtherUsers, + getAllUsers, + logoutUser, +} from "../../db/mongo/modules/userModule.js"; +import { errorMessages } from "../../utils/messages.js"; + +const mockUser = { + email: "test@test.com", + password: "password", + role: ["user"], +}; +const mockSuperUser = { + email: "test@test.com", + password: "password", + role: ["superadmin"], +}; +const imageFile = { + image: 1, +}; + +describe("userModule", function () { + let teamSaveStub, + teamFindByIdAndDeleteStub, + userSaveStub, + userFindStub, + userFindOneStub, + userFindByIdAndUpdateStub, + userFindByIdAndDeleteStub, + userDeleteManyStub, + userUpdateOneStub, + generateAvatarImageStub, + parseBooleanStub; + + beforeEach(function () { + teamSaveStub = sinon.stub(TeamModel.prototype, "save"); + teamFindByIdAndDeleteStub = sinon.stub(TeamModel, "findByIdAndDelete"); + userSaveStub = sinon.stub(UserModel.prototype, "save"); + userFindStub = sinon.stub(UserModel, "find"); + userFindOneStub = sinon.stub(UserModel, "findOne"); + userFindByIdAndUpdateStub = sinon.stub(UserModel, "findByIdAndUpdate"); + userFindByIdAndDeleteStub = sinon.stub(UserModel, "findByIdAndDelete"); + userDeleteManyStub = sinon.stub(UserModel, "deleteMany"); + userUpdateOneStub = sinon.stub(UserModel, "updateOne"); + generateAvatarImageStub = sinon.stub().resolves({ image: 2 }); + parseBooleanStub = sinon.stub().returns(true); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("insertUser", function () { + it("should insert a regular user", async function () { + userSaveStub.resolves(mockUser); + userFindOneStub.returns({ + select: sinon.stub().returns({ + select: sinon.stub().resolves(mockUser), + }), + }); + const result = await insertUser(mockUser, imageFile, generateAvatarImageStub); + expect(result).to.deep.equal(mockUser); + }); + + it("should insert a superadmin user", async function () { + userSaveStub.resolves(mockSuperUser); + userFindOneStub.returns({ + select: sinon.stub().returns({ + select: sinon.stub().resolves(mockSuperUser), + }), + }); + const result = await insertUser(mockSuperUser, imageFile, generateAvatarImageStub); + expect(result).to.deep.equal(mockSuperUser); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + userSaveStub.rejects(err); + try { + await insertUser(mockUser, imageFile, generateAvatarImageStub); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + + it("should handle a duplicate key error", async function () { + const err = new Error("test error"); + err.code = 11000; + userSaveStub.rejects(err); + try { + await insertUser(mockUser, imageFile, generateAvatarImageStub); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getUserByEmail", function () { + it("should return a user", async function () { + userFindOneStub.returns({ + select: sinon.stub().resolves(mockUser), + }); + const result = await getUserByEmail(mockUser.email); + expect(result).to.deep.equal(mockUser); + }); + }); + + describe("getUserByEmail", function () { + it("throw an error if a user is not found", async function () { + userFindOneStub.returns({ + select: sinon.stub().resolves(null), + }); + try { + await getUserByEmail(mockUser.email); + } catch (error) { + expect(error.message).to.equal(errorMessages.DB_USER_NOT_FOUND); + } + }); + }); + + describe("updateUser", function () { + let req, res; + + beforeEach(function () { + req = { + params: { + userId: "testId", + }, + body: { + deleteProfileImage: "false", + email: "test@test.com", + }, + file: { + buffer: "test", + mimetype: "test", + }, + }; + res = {}; + }); + + afterEach(function () {}); + + it("should update a user", async function () { + parseBooleanStub.returns(false); + userFindByIdAndUpdateStub.returns({ + select: sinon.stub().returns({ + select: sinon.stub().resolves(mockUser), + }), + }); + const result = await updateUser( + req, + res, + parseBooleanStub, + generateAvatarImageStub + ); + expect(result).to.deep.equal(mockUser); + }); + + it("should delete a user profile image", async function () { + req.body.deleteProfileImage = "true"; + userFindByIdAndUpdateStub.returns({ + select: sinon.stub().returns({ + select: sinon.stub().resolves(mockUser), + }), + }); + const result = await updateUser( + req, + res, + parseBooleanStub, + generateAvatarImageStub + ); + expect(result).to.deep.equal(mockUser); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + userFindByIdAndUpdateStub.throws(err); + try { + await updateUser(req, res, parseBooleanStub, generateAvatarImageStub); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deleteUser", function () { + it("should return a deleted user", async function () { + userFindByIdAndDeleteStub.resolves(mockUser); + const result = await deleteUser("testId"); + expect(result).to.deep.equal(mockUser); + }); + + it("should throw an error if a user is not found", async function () { + try { + await deleteUser("testId"); + } catch (error) { + expect(error).to.exist; + expect(error.message).to.equal(errorMessages.DB_USER_NOT_FOUND); + } + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + userFindByIdAndDeleteStub.throws(err); + try { + await deleteUser("testId"); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deleteTeam", function () { + it("should return true if team deleted", async function () { + teamFindByIdAndDeleteStub.resolves(); + const result = await deleteTeam("testId"); + expect(result).to.equal(true); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + teamFindByIdAndDeleteStub.throws(err); + try { + await deleteTeam("testId"); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); + + describe("deleteAllOtherUsers", function () { + it("should return true if all other users deleted", async function () { + userDeleteManyStub.resolves(true); + const result = await deleteAllOtherUsers(); + expect(result).to.equal(true); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + userDeleteManyStub.throws(err); + try { + await deleteAllOtherUsers(); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); + + describe("getAllUsers", function () { + it("should return all users", async function () { + userFindStub.returns({ + select: sinon.stub().returns({ + select: sinon.stub().resolves([mockUser]), + }), + }); + const result = await getAllUsers(); + expect(result).to.deep.equal([mockUser]); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + userFindStub.throws(err); + try { + await getAllUsers(); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); + + describe("logoutUser", function () { + it("should return true if user logged out", async function () { + userUpdateOneStub.resolves(true); + const result = await logoutUser("testId"); + expect(result).to.equal(true); + }); + + it("should handle an error", async function () { + const err = new Error("test error"); + userUpdateOneStub.throws(err); + try { + await logoutUser("testId"); + } catch (error) { + expect(error).to.exist; + expect(error).to.deep.equal(err); + } + }); + }); +}); diff --git a/server/tests/services/emailService.test.js b/server/tests/services/emailService.test.js new file mode 100755 index 000000000..541789d99 --- /dev/null +++ b/server/tests/services/emailService.test.js @@ -0,0 +1,212 @@ +import sinon from "sinon"; +import EmailService from "../../service/emailService.js"; + +describe("EmailService - Constructor", function () { + let settingsServiceMock; + let fsMock; + let pathMock; + let compileMock; + let mjml2htmlMock; + let nodemailerMock; + let loggerMock; + + beforeEach(function () { + settingsServiceMock = { + getSettings: sinon.stub().returns({ + systemEmailHost: "smtp.example.com", + systemEmailPort: 465, + systemEmailAddress: "test@example.com", + systemEmailPassword: "password", + }), + }; + + fsMock = { + readFileSync: sinon.stub().returns(""), + }; + + pathMock = { + join: sinon.stub().callsFake((...args) => args.join("/")), + }; + + compileMock = sinon.stub().returns(() => ""); + + mjml2htmlMock = sinon.stub().returns({ html: "" }); + + nodemailerMock = { + createTransport: sinon.stub().returns({ + sendMail: sinon.stub().resolves({ messageId: "12345" }), + }), + }; + + loggerMock = { + error: sinon.stub(), + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should initialize template loaders and email transporter", function () { + const emailService = new EmailService( + settingsServiceMock, + fsMock, + pathMock, + compileMock, + mjml2htmlMock, + nodemailerMock, + loggerMock + ); + + // Verify that the settingsService is assigned correctly + expect(emailService.settingsService).to.equal(settingsServiceMock); + + // Verify that the template loaders are initialized + expect(emailService.templateLookup.welcomeEmailTemplate).to.be.a("function"); + expect(emailService.templateLookup.employeeActivationTemplate).to.be.a("function"); + + // Verify that the email transporter is initialized + expect(nodemailerMock.createTransport.calledOnce).to.be.true; + const emailConfig = nodemailerMock.createTransport.getCall(0).args[0]; + expect(emailConfig).to.deep.equal({ + host: "smtp.example.com", + port: 465, + secure: true, + auth: { + user: "test@example.com", + pass: "password", + }, + }); + }); + + it("should have undefined templates if FS fails", function () { + fsMock = { + readFileSync: sinon.stub().throws(new Error("File read error")), + }; + const emailService = new EmailService( + settingsServiceMock, + fsMock, + pathMock, + compileMock, + mjml2htmlMock, + nodemailerMock, + loggerMock + ); + expect(loggerMock.error.called).to.be.true; + expect(loggerMock.error.firstCall.args[0].message).to.equal("File read error"); + }); +}); + +describe("EmailService - buildAndSendEmail", function () { + let settingsServiceMock; + let fsMock; + let pathMock; + let compileMock; + let mjml2htmlMock; + let nodemailerMock; + let loggerMock; + let emailService; + + beforeEach(function () { + settingsServiceMock = { + getSettings: sinon.stub().returns({ + systemEmailHost: "smtp.example.com", + systemEmailPort: 465, + systemEmailAddress: "test@example.com", + systemEmailPassword: "password", + }), + }; + + fsMock = { + readFileSync: sinon.stub().returns(""), + }; + + pathMock = { + join: sinon.stub().callsFake((...args) => args.join("/")), + }; + + compileMock = sinon.stub().returns(() => ""); + + mjml2htmlMock = sinon.stub().returns({ html: "" }); + + nodemailerMock = { + createTransport: sinon.stub().returns({ + sendMail: sinon.stub().resolves({ messageId: "12345" }), + }), + }; + + loggerMock = { + error: sinon.stub(), + }; + + emailService = new EmailService( + settingsServiceMock, + fsMock, + pathMock, + compileMock, + mjml2htmlMock, + nodemailerMock, + loggerMock + ); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should build and send email successfully", async function () { + const messageId = await emailService.buildAndSendEmail( + "welcomeEmailTemplate", + {}, + "recipient@example.com", + "Welcome" + ); + + expect(messageId).to.equal("12345"); + expect(nodemailerMock.createTransport().sendMail.calledOnce).to.be.true; + }); + + it("should log error if building HTML fails", async function () { + mjml2htmlMock.throws(new Error("MJML error")); + + const messageId = await emailService.buildAndSendEmail( + "welcomeEmailTemplate", + {}, + "recipient@example.com", + "Welcome" + ); + expect(loggerMock.error.calledOnce).to.be.true; + expect(loggerMock.error.getCall(0).args[0].message).to.equal("MJML error"); + }); + + it("should log error if sending email fails", async function () { + nodemailerMock.createTransport().sendMail.rejects(new Error("SMTP error")); + await emailService.buildAndSendEmail( + "welcomeEmailTemplate", + {}, + "recipient@example.com", + "Welcome" + ); + expect(loggerMock.error.calledOnce).to.be.true; + expect(loggerMock.error.getCall(0).args[0].message).to.equal("SMTP error"); + }); + + it("should log error if both building HTML and sending email fail", async function () { + mjml2htmlMock.throws(new Error("MJML error")); + nodemailerMock.createTransport().sendMail.rejects(new Error("SMTP error")); + + const messageId = await emailService.buildAndSendEmail( + "welcomeEmailTemplate", + {}, + "recipient@example.com", + "Welcome" + ); + + expect(messageId).to.be.undefined; + expect(loggerMock.error.calledTwice).to.be.true; + expect(loggerMock.error.getCall(0).args[0].message).to.equal("MJML error"); + expect(loggerMock.error.getCall(1).args[0].message).to.equal("SMTP error"); + }); + + it("should log an error if buildHtml fails", async function () {}); +}); diff --git a/server/tests/services/jobQueue.test.js b/server/tests/services/jobQueue.test.js new file mode 100755 index 000000000..88d57f339 --- /dev/null +++ b/server/tests/services/jobQueue.test.js @@ -0,0 +1,834 @@ +import sinon from "sinon"; +import JobQueue from "../../service/jobQueue.js"; +import { log } from "console"; + +class QueueStub { + constructor(queueName, options) { + this.queueName = queueName; + this.options = options; + this.workers = []; + this.jobs = []; + } + + // Add any methods that are expected to be called on the Queue instance + add(job) { + this.jobs.push(job); + } + + removeRepeatable(id) { + const removedJob = this.jobs.find((job) => job.data._id === id); + this.jobs = this.jobs.filter((job) => job.data._id !== id); + if (removedJob) { + return true; + } + return false; + } + + getRepeatableJobs() { + return this.jobs; + } + async getJobs() { + return this.jobs; + } + + async pause() { + return true; + } + + async obliterate() { + return true; + } +} + +class WorkerStub { + constructor(QUEUE_NAME, workerTask) { + this.queueName = QUEUE_NAME; + this.workerTask = async () => workerTask({ data: { _id: 1 } }); + } + + async close() { + return true; + } +} + +describe("JobQueue", function () { + let settingsService, + logger, + db, + networkService, + statusService, + notificationService, + jobQueue; + + beforeEach(async function () { + settingsService = { getSettings: sinon.stub() }; + statusService = { updateStatus: sinon.stub() }; + notificationService = { handleNotifications: sinon.stub() }; + + logger = { error: sinon.stub(), info: sinon.stub() }; + db = { + getAllMonitors: sinon.stub().returns([]), + getMaintenanceWindowsByMonitorId: sinon.stub().returns([]), + }; + networkService = { getStatus: sinon.stub() }; + jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("createJobQueue", function () { + it("should create a new JobQueue and add jobs for active monitors", async function () { + db.getAllMonitors.returns([ + { id: 1, isActive: true }, + { id: 2, isActive: true }, + ]); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + // There should be double the jobs, as one is meant to be instantly executed + // And one is meant to be enqueued + expect(jobQueue.queue.jobs.length).to.equal(4); + }); + + it("should reject with an error if an error occurs", async function () { + db.getAllMonitors.throws("Error"); + try { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + } catch (error) { + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("createJobQueue"); + } + }); + + it("should reject with an error if an error occurs, should not overwrite error data", async function () { + const error = new Error("Error"); + error.service = "otherService"; + error.method = "otherMethod"; + db.getAllMonitors.throws(error); + + try { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + } catch (error) { + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + + describe("Constructor", function () { + it("should construct a new JobQueue with default port and host if not provided", async function () { + settingsService.getSettings.returns({}); + + expect(jobQueue.connection.host).to.equal("127.0.0.1"); + expect(jobQueue.connection.port).to.equal(6379); + }); + + it("should construct a new JobQueue with provided port and host", async function () { + settingsService.getSettings.returns({ redisHost: "localhost", redisPort: 1234 }); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + expect(jobQueue.connection.host).to.equal("localhost"); + expect(jobQueue.connection.port).to.equal(1234); + }); + }); + + describe("isMaintenanceWindow", function () { + it("should throw an error if error occurs", async function () { + db.getMaintenanceWindowsByMonitorId.throws("Error"); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + try { + jobQueue.isInMaintenanceWindow(1); + } catch (error) { + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("createWorker"); + } + }); + + it("should return true if in maintenance window with no repeat", async function () { + db.getMaintenanceWindowsByMonitorId.returns([ + { + active: true, + start: new Date(Date.now() - 1000).toISOString(), + end: new Date(Date.now() + 1000).toISOString(), + repeat: 0, + }, + ]); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const inWindow = await jobQueue.isInMaintenanceWindow(1); + expect(inWindow).to.be.true; + }); + + it("should return true if in maintenance window with repeat", async function () { + db.getMaintenanceWindowsByMonitorId.returns([ + { + active: true, + start: new Date(Date.now() - 10000).toISOString(), + end: new Date(Date.now() - 5000).toISOString(), + repeat: 1000, + }, + ]); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const inWindow = await jobQueue.isInMaintenanceWindow(1); + expect(inWindow).to.be.true; + }); + + it("should return false if in end < start", async function () { + db.getMaintenanceWindowsByMonitorId.returns([ + { + active: true, + start: new Date(Date.now() - 5000).toISOString(), + end: new Date(Date.now() - 10000).toISOString(), + repeat: 1000, + }, + ]); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const inWindow = await jobQueue.isInMaintenanceWindow(1); + expect(inWindow).to.be.false; + }); + + it("should return false if not in maintenance window", async function () { + db.getMaintenanceWindowsByMonitorId.returns([ + { + active: false, + start: new Date(Date.now() - 5000).toISOString(), + end: new Date(Date.now() - 10000).toISOString(), + repeat: 1000, + }, + ]); + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const inWindow = await jobQueue.isInMaintenanceWindow(1); + expect(inWindow).to.be.false; + }); + }); + + describe("createJobHandler", function () { + it("resolve to an error if an error is thrown within", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.isInMaintenanceWindow = sinon.stub().throws("Error"); + try { + const handler = jobQueue.createJobHandler(); + await handler({ data: { _id: 1 } }); + } catch (error) { + expect(error.service).to.equal("JobQueue"); + expect(error.details).to.equal(`Error processing job 1: Error`); + } + }); + + it("should log info if job is in maintenance window", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.isInMaintenanceWindow = sinon.stub().returns(true); + const handler = jobQueue.createJobHandler(); + await handler({ data: { _id: 1 } }); + expect(logger.info.calledOnce).to.be.true; + expect(logger.info.firstCall.args[0].message).to.equal( + "Monitor 1 is in maintenance window" + ); + }); + + it("should return if status has not changed", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.isInMaintenanceWindow = sinon.stub().returns(false); + statusService.updateStatus = sinon.stub().returns({ statusChanged: false }); + const handler = jobQueue.createJobHandler(); + await handler({ data: { _id: 1 } }); + expect(jobQueue.notificationService.handleNotifications.notCalled).to.be.true; + }); + + it("should return if status has changed, but prevStatus was undefined (monitor paused)", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.isInMaintenanceWindow = sinon.stub().returns(false); + statusService.updateStatus = sinon + .stub() + .returns({ statusChanged: true, prevStatus: undefined }); + const handler = jobQueue.createJobHandler(); + await handler({ data: { _id: 1 } }); + expect(jobQueue.notificationService.handleNotifications.notCalled).to.be.true; + }); + + it("should call notification service if status changed and monitor was not paused", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.isInMaintenanceWindow = sinon.stub().returns(false); + statusService.updateStatus = sinon + .stub() + .returns({ statusChanged: true, prevStatus: false }); + const handler = jobQueue.createJobHandler(); + await handler({ data: { _id: 1 } }); + expect(jobQueue.notificationService.handleNotifications.calledOnce).to.be.true; + }); + }); + + describe("getWorkerStats", function () { + it("should throw an error if getRepeatable Jobs fails", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.getRepeatableJobs = async () => { + throw new Error("Error"); + }; + try { + const stats = await jobQueue.getWorkerStats(); + } catch (error) { + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("getWorkerStats"); + } + }); + + it("should throw an error if getRepeatable Jobs fails but respect existing error data", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.queue.getRepeatableJobs = async () => { + const error = new Error("Existing Error"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }; + try { + await jobQueue.getWorkerStats(); + } catch (error) { + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + + describe("scaleWorkers", function () { + it("should scale workers to 5 if no workers", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + expect(jobQueue.workers.length).to.equal(5); + }); + + it("should scale workers up", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + jobQueue.scaleWorkers({ + load: 100, + jobs: Array.from({ length: 100 }, (_, i) => i + 1), + }); + expect(jobQueue.workers.length).to.equal(20); + }); + + it("should scale workers down, even with error of worker.close fails", async function () { + WorkerStub.prototype.close = async () => { + throw new Error("Error"); + }; + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + await jobQueue.scaleWorkers({ + load: 100, + jobs: Array.from({ length: 100 }, (_, i) => i + 1), + }); + + const res = await jobQueue.scaleWorkers({ + load: 0, + jobs: [], + }); + expect(jobQueue.workers.length).to.equal(5); + }); + + it("should scale workers down", async function () { + WorkerStub.prototype.close = async () => { + return true; + }; + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + await jobQueue.scaleWorkers({ + load: 40, + jobs: Array.from({ length: 40 }, (_, i) => i + 1), + }); + + const res = await jobQueue.scaleWorkers({ + load: 0, + jobs: [], + }); + expect(jobQueue.workers.length).to.equal(5); + }); + + it("should return false if scaling doesn't happen", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const res = await jobQueue.scaleWorkers({ load: 5 }); + expect(jobQueue.workers.length).to.equal(5); + expect(res).to.be.false; + }); + }); + + describe("getJobs", function () { + it("should return jobs", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + const jobs = await jobQueue.getJobs(); + expect(jobs.length).to.equal(0); + }); + + it("should throw an error if getRepeatableJobs fails", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + try { + jobQueue.queue.getRepeatableJobs = async () => { + throw new Error("error"); + }; + + await jobQueue.getJobs(true); + } catch (error) { + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("getJobs"); + } + }); + + it("should throw an error if getRepeatableJobs fails but respect existing error data", async function () { + const jobQueue = await JobQueue.createJobQueue( + db, + networkService, + statusService, + notificationService, + settingsService, + logger, + QueueStub, + WorkerStub + ); + try { + jobQueue.queue.getRepeatableJobs = async () => { + const error = new Error("Existing error"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }; + + await jobQueue.getJobs(true); + } catch (error) { + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + + describe("getJobStats", function () { + it("should return job stats for no jobs", async function () { + const jobStats = await jobQueue.getJobStats(); + expect(jobStats).to.deep.equal({ jobs: [], workers: 5 }); + }); + + it("should return job stats for jobs", async function () { + jobQueue.queue.getJobs = async () => { + return [{ data: { url: "test" }, getState: async () => "completed" }]; + }; + const jobStats = await jobQueue.getJobStats(); + expect(jobStats).to.deep.equal({ + jobs: [{ url: "test", state: "completed" }], + workers: 5, + }); + }); + + it("should reject with an error if mapping jobs fails", async function () { + jobQueue.queue.getJobs = async () => { + return [ + { + data: { url: "test" }, + getState: async () => { + throw new Error("Mapping Error"); + }, + }, + ]; + }; + try { + await jobQueue.getJobStats(); + } catch (error) { + expect(error.message).to.equal("Mapping Error"); + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("getJobStats"); + } + }); + + it("should reject with an error if mapping jobs fails but respect existing error data", async function () { + jobQueue.queue.getJobs = async () => { + return [ + { + data: { url: "test" }, + getState: async () => { + const error = new Error("Mapping Error"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }, + }, + ]; + }; + try { + await jobQueue.getJobStats(); + } catch (error) { + expect(error.message).to.equal("Mapping Error"); + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + + describe("addJob", function () { + it("should add a job to the queue", async function () { + jobQueue.addJob("test", { url: "test" }); + expect(jobQueue.queue.jobs.length).to.equal(1); + }); + + it("should reject with an error if adding fails", async function () { + jobQueue.queue.add = async () => { + throw new Error("Error adding job"); + }; + try { + await jobQueue.addJob("test", { url: "test" }); + } catch (error) { + expect(error.message).to.equal("Error adding job"); + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("addJob"); + } + }); + + it("should reject with an error if adding fails but respect existing error data", async function () { + jobQueue.queue.add = async () => { + const error = new Error("Error adding job"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }; + try { + await jobQueue.addJob("test", { url: "test" }); + } catch (error) { + expect(error.message).to.equal("Error adding job"); + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + + describe("deleteJob", function () { + it("should delete a job from the queue", async function () { + jobQueue.getWorkerStats = sinon.stub().returns({ load: 1, jobs: [{}] }); + jobQueue.scaleWorkers = sinon.stub(); + const monitor = { _id: 1 }; + const job = { data: monitor }; + jobQueue.queue.jobs = [job]; + await jobQueue.deleteJob(monitor); + // expect(jobQueue.queue.jobs.length).to.equal(0); + // expect(logger.info.calledOnce).to.be.true; + // expect(jobQueue.getWorkerStats.calledOnce).to.be.true; + // expect(jobQueue.scaleWorkers.calledOnce).to.be.true; + }); + + it("should log an error if job is not found", async function () { + jobQueue.getWorkerStats = sinon.stub().returns({ load: 1, jobs: [{}] }); + jobQueue.scaleWorkers = sinon.stub(); + const monitor = { _id: 1 }; + const job = { data: monitor }; + jobQueue.queue.jobs = [job]; + await jobQueue.deleteJob({ id_: 2 }); + expect(logger.error.calledOnce).to.be.true; + }); + + it("should reject with an error if removeRepeatable fails", async function () { + jobQueue.queue.removeRepeatable = async () => { + const error = new Error("removeRepeatable error"); + throw error; + }; + + try { + await jobQueue.deleteJob({ _id: 1 }); + } catch (error) { + expect(error.message).to.equal("removeRepeatable error"); + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("deleteJob"); + } + }); + + it("should reject with an error if removeRepeatable fails but respect existing error data", async function () { + jobQueue.queue.removeRepeatable = async () => { + const error = new Error("removeRepeatable error"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }; + + try { + await jobQueue.deleteJob({ _id: 1 }); + } catch (error) { + expect(error.message).to.equal("removeRepeatable error"); + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); + + describe("getMetrics", function () { + it("should return metrics for the job queue", async function () { + jobQueue.queue.getWaitingCount = async () => 1; + jobQueue.queue.getActiveCount = async () => 2; + jobQueue.queue.getCompletedCount = async () => 3; + jobQueue.queue.getFailedCount = async () => 4; + jobQueue.queue.getDelayedCount = async () => 5; + jobQueue.queue.getRepeatableJobs = async () => [1, 2, 3]; + const metrics = await jobQueue.getMetrics(); + expect(metrics).to.deep.equal({ + waiting: 1, + active: 2, + completed: 3, + failed: 4, + delayed: 5, + repeatableJobs: 3, + }); + }); + + it("should log an error if metrics operations fail", async function () { + jobQueue.queue.getWaitingCount = async () => { + throw new Error("Error"); + }; + await jobQueue.getMetrics(); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0].message).to.equal("Error"); + }); + }); + + describe("obliterate", function () { + it("should return true if obliteration is successful", async function () { + jobQueue.queue.pause = async () => true; + jobQueue.getJobs = async () => [{ key: 1, id: 1 }]; + jobQueue.queue.removeRepeatableByKey = async () => true; + jobQueue.queue.remove = async () => true; + jobQueue.queue.obliterate = async () => true; + const obliteration = await jobQueue.obliterate(); + expect(obliteration).to.be.true; + }); + + it("should throw an error if obliteration fails", async function () { + jobQueue.getMetrics = async () => { + throw new Error("Error"); + }; + + try { + await jobQueue.obliterate(); + } catch (error) { + expect(error.service).to.equal("JobQueue"); + expect(error.method).to.equal("obliterate"); + } + }); + + it("should throw an error if obliteration fails but respect existing error data", async function () { + jobQueue.getMetrics = async () => { + const error = new Error("Error"); + error.service = "otherService"; + error.method = "otherMethod"; + throw error; + }; + + try { + await jobQueue.obliterate(); + } catch (error) { + expect(error.service).to.equal("otherService"); + expect(error.method).to.equal("otherMethod"); + } + }); + }); +}); diff --git a/server/tests/services/networkService.test.js b/server/tests/services/networkService.test.js new file mode 100755 index 000000000..44ddf8576 --- /dev/null +++ b/server/tests/services/networkService.test.js @@ -0,0 +1,455 @@ +import sinon from "sinon"; +import NetworkService from "../../service/networkService.js"; +import { expect } from "chai"; +import http from "http"; +import { errorMessages } from "../../utils/messages.js"; +describe("Network Service", function () { + let axios, ping, Docker, logger, networkService; + + beforeEach(function () { + axios = { + get: sinon.stub().resolves({ + data: { foo: "bar" }, + status: 200, + }), + }; + Docker = class { + listContainers = sinon.stub().resolves([ + { + Names: ["http://test.com"], + Id: "http://test.com", + }, + ]); + getContainer = sinon.stub().returns({ + inspect: sinon.stub().resolves({ State: { Status: "running" } }), + }); + }; + ping = { + promise: { + probe: sinon + .stub() + .resolves({ response: { alive: true }, responseTime: 100, alive: true }), + }, + }; + logger = { error: sinon.stub() }; + networkService = new NetworkService(axios, ping, logger, http, Docker); + }); + + describe("constructor", function () { + it("should create a new NetworkService instance", function () { + const networkService = new NetworkService(); + expect(networkService).to.be.an.instanceOf(NetworkService); + }); + }); + + describe("timeRequest", function () { + it("should time an asynchronous operation", async function () { + const operation = sinon.stub().resolves("success"); + const { response, responseTime } = await networkService.timeRequest(operation); + expect(response).to.equal("success"); + expect(responseTime).to.be.a("number"); + }); + + it("should handle errors if operation throws error", async function () { + const error = new Error("Test error"); + const operation = sinon.stub().throws(error); + const { response, responseTime } = await networkService.timeRequest(operation); + expect(response).to.be.null; + expect(responseTime).to.be.a("number"); + expect(error.message).to.equal("Test error"); + }); + }); + + describe("requestPing", function () { + it("should return a response object if ping successful", async function () { + const pingResult = await networkService.requestPing({ + data: { url: "http://test.com", _id: "123" }, + }); + expect(pingResult.monitorId).to.equal("123"); + expect(pingResult.type).to.equal("ping"); + expect(pingResult.responseTime).to.be.a("number"); + expect(pingResult.status).to.be.true; + }); + + it("should return a response object if ping unsuccessful", async function () { + const error = new Error("Test error"); + networkService.timeRequest = sinon + .stub() + .resolves({ response: null, responseTime: 1, error }); + const pingResult = await networkService.requestPing({ + data: { url: "http://test.com", _id: "123" }, + }); + expect(pingResult.monitorId).to.equal("123"); + expect(pingResult.type).to.equal("ping"); + expect(pingResult.responseTime).to.be.a("number"); + expect(pingResult.status).to.be.false; + expect(pingResult.code).to.equal(networkService.PING_ERROR); + }); + + it("should throw an error if ping cannot resolve", async function () { + const error = new Error("test error"); + networkService.timeRequest = sinon.stub().throws(error); + try { + await networkService.requestPing({ + data: { url: "http://test.com", _id: "123" }, + }); + } catch (error) { + expect(error).to.exist; + expect(error.method).to.equal("requestPing"); + } + }); + }); + + describe("requestHttp", function () { + it("should return a response object if http successful", async function () { + const job = { data: { url: "http://test.com", _id: "123", type: "http" } }; + const httpResult = await networkService.requestHttp(job); + expect(httpResult.monitorId).to.equal("123"); + expect(httpResult.type).to.equal("http"); + expect(httpResult.responseTime).to.be.a("number"); + expect(httpResult.status).to.be.true; + }); + + it("should return a response object if http unsuccessful", async function () { + const error = new Error("Test error"); + error.response = { status: 404 }; + networkService.timeRequest = sinon + .stub() + .resolves({ response: null, responseTime: 1, error }); + const job = { data: { url: "http://test.com", _id: "123", type: "http" } }; + const httpResult = await networkService.requestHttp(job); + expect(httpResult.monitorId).to.equal("123"); + expect(httpResult.type).to.equal("http"); + expect(httpResult.responseTime).to.be.a("number"); + expect(httpResult.status).to.be.false; + expect(httpResult.code).to.equal(404); + }); + + it("should return a response object if http unsuccessful with unknown code", async function () { + const error = new Error("Test error"); + error.response = {}; + networkService.timeRequest = sinon + .stub() + .resolves({ response: null, responseTime: 1, error }); + const job = { data: { url: "http://test.com", _id: "123", type: "http" } }; + const httpResult = await networkService.requestHttp(job); + expect(httpResult.monitorId).to.equal("123"); + expect(httpResult.type).to.equal("http"); + expect(httpResult.responseTime).to.be.a("number"); + expect(httpResult.status).to.be.false; + expect(httpResult.code).to.equal(networkService.NETWORK_ERROR); + }); + + it("should throw an error if an error occurs", async function () { + const error = new Error("test error"); + networkService.timeRequest = sinon.stub().throws(error); + try { + await networkService.requestHttp({ + data: { url: "http://test.com", _id: "123" }, + }); + } catch (error) { + expect(error).to.exist; + expect(error.method).to.equal("requestHttp"); + } + }); + }); + + describe("requestPagespeed", function () { + it("should return a response object if pagespeed successful", async function () { + const job = { data: { url: "http://test.com", _id: "123", type: "pagespeed" } }; + const pagespeedResult = await networkService.requestPagespeed(job); + expect(pagespeedResult.monitorId).to.equal("123"); + expect(pagespeedResult.type).to.equal("pagespeed"); + expect(pagespeedResult.responseTime).to.be.a("number"); + expect(pagespeedResult.status).to.be.true; + }); + + it("should return a response object if pagespeed unsuccessful", async function () { + const error = new Error("Test error"); + error.response = { status: 404 }; + networkService.timeRequest = sinon + .stub() + .resolves({ response: null, responseTime: 1, error }); + const job = { data: { url: "http://test.com", _id: "123", type: "pagespeed" } }; + const pagespeedResult = await networkService.requestPagespeed(job); + expect(pagespeedResult.monitorId).to.equal("123"); + expect(pagespeedResult.type).to.equal("pagespeed"); + expect(pagespeedResult.responseTime).to.be.a("number"); + expect(pagespeedResult.status).to.be.false; + expect(pagespeedResult.code).to.equal(404); + }); + + it("should return a response object if pagespeed unsuccessful with an unknown code", async function () { + const error = new Error("Test error"); + error.response = {}; + networkService.timeRequest = sinon + .stub() + .resolves({ response: null, responseTime: 1, error }); + const job = { data: { url: "http://test.com", _id: "123", type: "pagespeed" } }; + const pagespeedResult = await networkService.requestPagespeed(job); + expect(pagespeedResult.monitorId).to.equal("123"); + expect(pagespeedResult.type).to.equal("pagespeed"); + expect(pagespeedResult.responseTime).to.be.a("number"); + expect(pagespeedResult.status).to.be.false; + expect(pagespeedResult.code).to.equal(networkService.NETWORK_ERROR); + }); + + it("should throw an error if pagespeed cannot resolve", async function () { + const error = new Error("test error"); + networkService.timeRequest = sinon.stub().throws(error); + try { + await networkService.requestPagespeed({ + data: { url: "http://test.com", _id: "123" }, + }); + } catch (error) { + expect(error).to.exist; + expect(error.method).to.equal("requestPagespeed"); + } + }); + }); + + describe("requestHardware", function () { + it("should return a response object if hardware successful", async function () { + const job = { data: { url: "http://test.com", _id: "123", type: "hardware" } }; + const httpResult = await networkService.requestHardware(job); + expect(httpResult.monitorId).to.equal("123"); + expect(httpResult.type).to.equal("hardware"); + expect(httpResult.responseTime).to.be.a("number"); + expect(httpResult.status).to.be.true; + }); + + it("should return a response object if hardware successful and job has a secret", async function () { + const job = { + data: { + url: "http://test.com", + _id: "123", + type: "hardware", + secret: "my_secret", + }, + }; + const httpResult = await networkService.requestHardware(job); + expect(httpResult.monitorId).to.equal("123"); + expect(httpResult.type).to.equal("hardware"); + expect(httpResult.responseTime).to.be.a("number"); + expect(httpResult.status).to.be.true; + }); + + it("should return a response object if hardware unsuccessful", async function () { + const error = new Error("Test error"); + error.response = { status: 404 }; + networkService.timeRequest = sinon + .stub() + .resolves({ response: null, responseTime: 1, error }); + const job = { data: { url: "http://test.com", _id: "123", type: "hardware" } }; + const httpResult = await networkService.requestHardware(job); + expect(httpResult.monitorId).to.equal("123"); + expect(httpResult.type).to.equal("hardware"); + expect(httpResult.responseTime).to.be.a("number"); + expect(httpResult.status).to.be.false; + expect(httpResult.code).to.equal(404); + }); + + it("should return a response object if hardware unsuccessful with unknown code", async function () { + const error = new Error("Test error"); + error.response = {}; + networkService.timeRequest = sinon + .stub() + .resolves({ response: null, responseTime: 1, error }); + const job = { data: { url: "http://test.com", _id: "123", type: "hardware" } }; + const httpResult = await networkService.requestHardware(job); + expect(httpResult.monitorId).to.equal("123"); + expect(httpResult.type).to.equal("hardware"); + expect(httpResult.responseTime).to.be.a("number"); + expect(httpResult.status).to.be.false; + expect(httpResult.code).to.equal(networkService.NETWORK_ERROR); + }); + + it("should throw an error if hardware cannot resolve", async function () { + const error = new Error("test error"); + networkService.timeRequest = sinon.stub().throws(error); + try { + await networkService.requestHardware({ + data: { url: "http://test.com", _id: "123" }, + }); + } catch (error) { + expect(error).to.exist; + expect(error.method).to.equal("requestHardware"); + } + }); + }); + + describe("requestDocker", function () { + it("should return a response object if docker successful", async function () { + const job = { data: { url: "http://test.com", _id: "123", type: "docker" } }; + const dockerResult = await networkService.requestDocker(job); + expect(dockerResult.monitorId).to.equal("123"); + expect(dockerResult.type).to.equal("docker"); + expect(dockerResult.responseTime).to.be.a("number"); + expect(dockerResult.status).to.be.true; + }); + + it("should return a response object with status false if container not running", async function () { + Docker = class { + listContainers = sinon.stub().resolves([ + { + Names: ["/my_container"], + Id: "abc123", + }, + ]); + getContainer = sinon.stub().returns({ + inspect: sinon.stub().resolves({ State: { Status: "stopped" } }), + }); + }; + networkService = new NetworkService(axios, ping, logger, http, Docker); + const job = { data: { url: "abc123", _id: "123", type: "docker" } }; + const dockerResult = await networkService.requestDocker(job); + expect(dockerResult.status).to.be.false; + expect(dockerResult.code).to.equal(200); + }); + + it("should handle an error when fetching the container", async function () { + Docker = class { + listContainers = sinon.stub().resolves([ + { + Names: ["/my_container"], + Id: "abc123", + }, + ]); + getContainer = sinon.stub().returns({ + inspect: sinon.stub().throws(new Error("test error")), + }); + }; + networkService = new NetworkService(axios, ping, logger, http, Docker); + const job = { data: { url: "abc123", _id: "123", type: "docker" } }; + const dockerResult = await networkService.requestDocker(job); + expect(dockerResult.status).to.be.false; + expect(dockerResult.code).to.equal(networkService.NETWORK_ERROR); + }); + + it("should throw an error if operations fail", async function () { + Docker = class { + listContainers = sinon.stub().resolves([ + { + Names: ["/my_container"], + Id: "abc123", + }, + ]); + getContainer = sinon.stub().throws(new Error("test error")); + }; + networkService = new NetworkService(axios, ping, logger, http, Docker); + const job = { data: { url: "abc123", _id: "123", type: "docker" } }; + try { + await networkService.requestDocker(job); + } catch (error) { + expect(error.message).to.equal("test error"); + } + }); + + it("should throw an error if no matching images found", async function () { + Docker = class { + listContainers = sinon.stub().resolves([]); + getContainer = sinon.stub().throws(new Error("test error")); + }; + networkService = new NetworkService(axios, ping, logger, http, Docker); + const job = { data: { url: "abc123", _id: "123", type: "docker" } }; + try { + await networkService.requestDocker(job); + } catch (error) { + expect(error.message).to.equal(errorMessages.DOCKER_NOT_FOUND); + } + }); + }); + + describe("getStatus", function () { + beforeEach(function () { + networkService.requestPing = sinon.stub(); + networkService.requestHttp = sinon.stub(); + networkService.requestPagespeed = sinon.stub(); + networkService.requestHardware = sinon.stub(); + networkService.requestDocker = sinon.stub(); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should call requestPing if type is ping", async function () { + await networkService.getStatus({ data: { type: "ping" } }); + expect(networkService.requestPing.calledOnce).to.be.true; + expect(networkService.requestDocker.notCalled).to.be.true; + expect(networkService.requestHttp.notCalled).to.be.true; + expect(networkService.requestPagespeed.notCalled).to.be.true; + }); + + it("should call requestHttp if type is http", async function () { + await networkService.getStatus({ data: { type: "http" } }); + expect(networkService.requestPing.notCalled).to.be.true; + expect(networkService.requestDocker.notCalled).to.be.true; + expect(networkService.requestHttp.calledOnce).to.be.true; + expect(networkService.requestPagespeed.notCalled).to.be.true; + }); + + it("should call requestPagespeed if type is pagespeed", async function () { + await networkService.getStatus({ data: { type: "pagespeed" } }); + expect(networkService.requestPing.notCalled).to.be.true; + expect(networkService.requestDocker.notCalled).to.be.true; + expect(networkService.requestHttp.notCalled).to.be.true; + expect(networkService.requestPagespeed.calledOnce).to.be.true; + }); + + it("should call requestHardware if type is hardware", async function () { + await networkService.getStatus({ data: { type: "hardware" } }); + expect(networkService.requestHardware.calledOnce).to.be.true; + expect(networkService.requestDocker.notCalled).to.be.true; + expect(networkService.requestPing.notCalled).to.be.true; + expect(networkService.requestPagespeed.notCalled).to.be.true; + }); + + it("should call requestDocker if type is Docker", async function () { + await networkService.getStatus({ data: { type: "docker" } }); + expect(networkService.requestDocker.calledOnce).to.be.true; + expect(networkService.requestHardware.notCalled).to.be.true; + expect(networkService.requestPing.notCalled).to.be.true; + expect(networkService.requestPagespeed.notCalled).to.be.true; + }); + + it("should throw an error if an unknown type is provided", async function () { + try { + await networkService.getStatus({ data: { type: "unknown" } }); + } catch (error) { + expect(error.service).to.equal("NetworkService"); + expect(error.method).to.equal("getStatus"); + expect(error.message).to.equal("Unsupported type: unknown"); + } + }); + + it("should throw an error if job type is undefined", async function () { + try { + await networkService.getStatus({ data: { type: undefined } }); + } catch (error) { + expect(error.service).to.equal("NetworkService"); + expect(error.method).to.equal("getStatus"); + expect(error.message).to.equal("Unsupported type: unknown"); + } + }); + + it("should throw an error if job is empty", async function () { + try { + await networkService.getStatus({}); + } catch (error) { + expect(error.method).to.equal("getStatus"); + expect(error.message).to.equal("Unsupported type: unknown"); + } + }); + + it("should throw an error if job is null", async function () { + try { + await networkService.getStatus(null); + } catch (error) { + expect(error.service).to.equal("NetworkService"); + expect(error.method).to.equal("getStatus"); + expect(error.message).to.equal("Unsupported type: unknown"); + } + }); + }); +}); diff --git a/server/tests/services/notificationService.test.js b/server/tests/services/notificationService.test.js new file mode 100755 index 000000000..207b40db0 --- /dev/null +++ b/server/tests/services/notificationService.test.js @@ -0,0 +1,302 @@ +import sinon from "sinon"; +import NotificationService from "../../service/notificationService.js"; +import { expect } from "chai"; + +describe("NotificationService", function () { + let emailService, db, logger, notificationService; + + beforeEach(function () { + db = { + getNotificationsByMonitorId: sinon.stub(), + }; + emailService = { + buildAndSendEmail: sinon.stub(), + }; + logger = { + warn: sinon.stub(), + }; + + notificationService = new NotificationService(emailService, db, logger); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("constructor", function () { + it("should create a new instance of NotificationService", function () { + expect(notificationService).to.be.an.instanceOf(NotificationService); + }); + }); + + describe("sendEmail", function () { + it("should send an email notification with Up Template", async function () { + const networkResponse = { + monitor: { + name: "Test Monitor", + url: "http://test.com", + }, + status: true, + prevStatus: false, + }; + const address = "test@test.com"; + await notificationService.sendEmail(networkResponse, address); + expect(notificationService.emailService.buildAndSendEmail.calledOnce).to.be.true; + expect( + notificationService.emailService.buildAndSendEmail.calledWith( + "serverIsUpTemplate", + { monitor: "Test Monitor", url: "http://test.com" } + ) + ); + }); + + it("should send an email notification with Down Template", async function () { + const networkResponse = { + monitor: { + name: "Test Monitor", + url: "http://test.com", + }, + status: false, + prevStatus: true, + }; + const address = "test@test.com"; + await notificationService.sendEmail(networkResponse, address); + expect(notificationService.emailService.buildAndSendEmail.calledOnce).to.be.true; + }); + + it("should send an email notification with Up Template", async function () { + const networkResponse = { + monitor: { + name: "Test Monitor", + url: "http://test.com", + }, + status: true, + prevStatus: false, + }; + const address = "test@test.com"; + await notificationService.sendEmail(networkResponse, address); + expect(notificationService.emailService.buildAndSendEmail.calledOnce).to.be.true; + }); + }); + + describe("handleNotifications", function () { + it("should handle notifications based on the network response", async function () { + notificationService.sendEmail = sinon.stub(); + const res = await notificationService.handleNotifications({ + monitor: { + type: "email", + address: "www.google.com", + }, + }); + expect(res).to.be.true; + }); + + it("should handle hardware notifications", async function () { + notificationService.sendEmail = sinon.stub(); + const res = await notificationService.handleNotifications({ + monitor: { + type: "hardware", + address: "www.google.com", + }, + }); + expect(res).to.be.true; + }); + + it("should handle an error when getting notifications", async function () { + const testError = new Error("Test Error"); + notificationService.db.getNotificationsByMonitorId.rejects(testError); + await notificationService.handleNotifications({ monitorId: "123" }); + expect(notificationService.logger.warn.calledOnce).to.be.true; + }); + }); + + describe("sendHardwareEmail", function () { + let networkResponse, address, alerts; + + beforeEach(function () { + networkResponse = { + monitor: { + name: "Test Monitor", + url: "http://test.com", + }, + status: true, + prevStatus: false, + }; + address = "test@test.com"; + alerts = ["test"]; + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should send an email notification with Hardware Template", async function () { + emailService.buildAndSendEmail.resolves(true); + const res = await notificationService.sendHardwareEmail( + networkResponse, + address, + alerts + ); + expect(res).to.be.true; + }); + + it("should return false if no alerts are provided", async function () { + alerts = []; + emailService.buildAndSendEmail.resolves(true); + const res = await notificationService.sendHardwareEmail( + networkResponse, + address, + alerts + ); + expect(res).to.be.false; + }); + }); + + describe("handleStatusNotifications", function () { + let networkResponse; + + beforeEach(function () { + networkResponse = { + monitor: { + name: "Test Monitor", + url: "http://test.com", + }, + statusChanged: true, + status: true, + prevStatus: false, + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should handle status notifications", async function () { + db.getNotificationsByMonitorId.resolves([ + { type: "email", address: "test@test.com" }, + ]); + const res = await notificationService.handleStatusNotifications(networkResponse); + expect(res).to.be.true; + }); + + it("should return false if status hasn't changed", async function () { + networkResponse.statusChanged = false; + const res = await notificationService.handleStatusNotifications(networkResponse); + expect(res).to.be.false; + }); + + it("should return false if prevStatus is undefined", async function () { + networkResponse.prevStatus = undefined; + const res = await notificationService.handleStatusNotifications(networkResponse); + expect(res).to.be.false; + }); + + it("should handle an error", async function () { + const testError = new Error("Test Error"); + db.getNotificationsByMonitorId.rejects(testError); + try { + await notificationService.handleStatusNotifications(networkResponse); + } catch (error) { + expect(error).to.be.an.instanceOf(Error); + expect(error.message).to.equal("Test Error"); + } + }); + }); + + describe("handleHardwareNotifications", function () { + let networkResponse; + + beforeEach(function () { + networkResponse = { + monitor: { + name: "Test Monitor", + url: "http://test.com", + thresholds: { + usage_cpu: 1, + usage_memory: 1, + usage_disk: 1, + }, + }, + payload: { + data: { + cpu: { + usage_percent: 0.655, + }, + memory: { + usage_percent: 0.783, + }, + disk: [ + { + name: "/dev/sda1", + usage_percent: 0.452, + }, + { + name: "/dev/sdb1", + usage_percent: 0.627, + }, + ], + }, + }, + }; + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("it should return false if no thresholds are set", function () { + it("should return false if no thresholds are set", async function () { + networkResponse.monitor.thresholds = undefined; + const res = + await notificationService.handleHardwareNotifications(networkResponse); + expect(res).to.be.false; + }); + + it("should return false if metrics are null", async function () { + networkResponse.payload.data = null; + const res = + await notificationService.handleHardwareNotifications(networkResponse); + expect(res).to.be.false; + }); + + it("should return true if request is well formed and thresholds > 0", async function () { + db.getNotificationsByMonitorId.resolves([ + { + type: "email", + address: "test@test.com", + alertThreshold: 1, + cpuAlertThreshold: 1, + memoryAlertThreshold: 1, + diskAlertThreshold: 1, + save: sinon.stub().resolves(), + }, + ]); + const res = + await notificationService.handleHardwareNotifications(networkResponse); + expect(res).to.be.true; + }); + + it("should return true if thresholds are exceeded", async function () { + db.getNotificationsByMonitorId.resolves([ + { + type: "email", + address: "test@test.com", + alertThreshold: 1, + cpuAlertThreshold: 1, + memoryAlertThreshold: 1, + diskAlertThreshold: 1, + save: sinon.stub().resolves(), + }, + ]); + networkResponse.monitor.thresholds = { + usage_cpu: 0.01, + usage_memory: 0.01, + usage_disk: 0.01, + }; + const res = + await notificationService.handleHardwareNotifications(networkResponse); + expect(res).to.be.true; + }); + }); + }); +}); diff --git a/server/tests/services/settingsService.test.js b/server/tests/services/settingsService.test.js new file mode 100755 index 000000000..3e04ced38 --- /dev/null +++ b/server/tests/services/settingsService.test.js @@ -0,0 +1,142 @@ +import sinon from "sinon"; +import SettingsService from "../../service/settingsService.js"; +import { expect } from "chai"; +import NetworkService from "../../service/networkService.js"; +const SERVICE_NAME = "SettingsService"; + +describe("SettingsService", function () { + let sandbox, mockAppSettings; + + beforeEach(function () { + sandbox = sinon.createSandbox(); + sandbox.stub(process.env, "CLIENT_HOST").value("http://localhost"); + sandbox.stub(process.env, "JWT_SECRET").value("secret"); + sandbox.stub(process.env, "REFRESH_TOKEN_SECRET").value("refreshSecret"); + sandbox.stub(process.env, "DB_TYPE").value("postgres"); + sandbox + .stub(process.env, "DB_CONNECTION_STRING") + .value("postgres://user:pass@localhost/db"); + sandbox.stub(process.env, "REDIS_HOST").value("localhost"); + sandbox.stub(process.env, "REDIS_PORT").value("6379"); + sandbox.stub(process.env, "TOKEN_TTL").value("3600"); + sandbox.stub(process.env, "REFRESH_TOKEN_TTL").value("86400"); + sandbox.stub(process.env, "PAGESPEED_API_KEY").value("apiKey"); + sandbox.stub(process.env, "SYSTEM_EMAIL_HOST").value("smtp.mailtrap.io"); + sandbox.stub(process.env, "SYSTEM_EMAIL_PORT").value("2525"); + sandbox.stub(process.env, "SYSTEM_EMAIL_ADDRESS").value("test@example.com"); + sandbox.stub(process.env, "SYSTEM_EMAIL_PASSWORD").value("password"); + }); + + mockAppSettings = { + settingOne: 123, + settingTwo: 456, + }; + + afterEach(function () { + sandbox.restore(); + sinon.restore(); + }); + + describe("constructor", function () { + it("should construct a new SettingsService", function () { + const settingsService = new SettingsService(mockAppSettings); + expect(settingsService.appSettings).to.equal(mockAppSettings); + }); + }); + + describe("loadSettings", function () { + it("should load settings from DB when environment variables are not set", async function () { + const dbSettings = { logLevel: "debug", apiBaseUrl: "http://localhost" }; + const appSettings = { findOne: sinon.stub().returns(dbSettings) }; + const settingsService = new SettingsService(appSettings); + settingsService.settings = {}; + const result = await settingsService.loadSettings(); + expect(result).to.deep.equal(dbSettings); + }); + + it("should throw an error if settings are not found", async function () { + const appSettings = { findOne: sinon.stub().returns(null) }; + const settingsService = new SettingsService(appSettings); + settingsService.settings = null; + + try { + await settingsService.loadSettings(); + } catch (error) { + expect(error.message).to.equal("Settings not found"); + expect(error.service).to.equal(SERVICE_NAME); + expect(error.method).to.equal("loadSettings"); + } + }); + + it("should add its method and service name to error if not present", async function () { + const appSettings = { findOne: sinon.stub().throws(new Error("Test error")) }; + const settingsService = new SettingsService(appSettings); + try { + await settingsService.loadSettings(); + } catch (error) { + expect(error.message).to.equal("Test error"); + expect(error.service).to.equal(SERVICE_NAME); + expect(error.method).to.equal("loadSettings"); + } + }); + + it("should not add its method and service name to error if present", async function () { + const error = new Error("Test error"); + error.method = "otherMethod"; + error.service = "OTHER_SERVICE"; + const appSettings = { findOne: sinon.stub().throws(error) }; + const settingsService = new SettingsService(appSettings); + try { + await settingsService.loadSettings(); + } catch (error) { + expect(error.message).to.equal("Test error"); + expect(error.service).to.equal("OTHER_SERVICE"); + expect(error.method).to.equal("otherMethod"); + } + }); + + it("should merge DB settings with environment variables", async function () { + const dbSettings = { logLevel: "debug", apiBaseUrl: "http://localhost" }; + const appSettings = { findOne: sinon.stub().returns(dbSettings) }; + const settingsService = new SettingsService(appSettings); + const result = await settingsService.loadSettings(); + expect(result).to.deep.equal(settingsService.settings); + expect(settingsService.settings.logLevel).to.equal("debug"); + expect(settingsService.settings.apiBaseUrl).to.equal("http://localhost"); + }); + }); + + describe("reloadSettings", function () { + it("should call loadSettings", async function () { + const dbSettings = { logLevel: "debug", apiBaseUrl: "http://localhost" }; + const appSettings = { findOne: sinon.stub().returns(dbSettings) }; + const settingsService = new SettingsService(appSettings); + settingsService.settings = {}; + const result = await settingsService.reloadSettings(); + expect(result).to.deep.equal(dbSettings); + }); + }); + + describe("getSettings", function () { + it("should return the current settings", function () { + const dbSettings = { logLevel: "debug", apiBaseUrl: "http://localhost" }; + const appSettings = { findOne: sinon.stub().returns(dbSettings) }; + const settingsService = new SettingsService(appSettings); + settingsService.settings = dbSettings; + const result = settingsService.getSettings(); + expect(result).to.deep.equal(dbSettings); + }); + + it("should throw an error if settings have not been loaded", function () { + const appSettings = { findOne: sinon.stub().returns(null) }; + const settingsService = new SettingsService(appSettings); + settingsService.settings = null; + + try { + settingsService.getSettings(); + } catch (error) { + expect(error.message).to.equal("Settings have not been loaded"); + } + }); + }); +}); diff --git a/server/tests/services/statusService.test.js b/server/tests/services/statusService.test.js new file mode 100755 index 000000000..aa8865024 --- /dev/null +++ b/server/tests/services/statusService.test.js @@ -0,0 +1,259 @@ +import sinon from "sinon"; +import StatusService from "../../service/statusService.js"; +import { afterEach, describe } from "node:test"; + +describe("StatusService", () => { + let db, logger, statusService; + + beforeEach(function () { + db = { + getMonitorById: sinon.stub(), + createCheck: sinon.stub(), + createPagespeedCheck: sinon.stub(), + }; + logger = { + info: sinon.stub(), + error: sinon.stub(), + }; + statusService = new StatusService(db, logger); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe("constructor", () => { + it("should create an instance of StatusService", function () { + expect(statusService).to.be.an.instanceOf(StatusService); + }); + }); + + describe("getStatusString", () => { + it("should return 'up' if status is true", function () { + expect(statusService.getStatusString(true)).to.equal("up"); + }); + + it("should return 'down' if status is false", function () { + expect(statusService.getStatusString(false)).to.equal("down"); + }); + + it("should return 'unknown' if status is undefined or null", function () { + expect(statusService.getStatusString(undefined)).to.equal("unknown"); + }); + }); + + describe("updateStatus", () => { + beforeEach(function () { + // statusService.insertCheck = sinon.stub().resolves; + }); + + afterEach(() => { + sinon.restore(); + }); + + it("should throw an error if an error occurs", async function () { + const error = new Error("Test error"); + statusService.db.getMonitorById = sinon.stub().throws(error); + try { + await statusService.updateStatus({ monitorId: "test", status: true }); + } catch (error) { + expect(error.message).to.equal("Test error"); + } + // expect(statusService.insertCheck.calledOnce).to.be.true; + }); + + it("should return {statusChanged: false} if status hasn't changed", async function () { + statusService.db.getMonitorById = sinon.stub().returns({ status: true }); + const result = await statusService.updateStatus({ + monitorId: "test", + status: true, + }); + expect(result).to.deep.equal({ statusChanged: false }); + // expect(statusService.insertCheck.calledOnce).to.be.true; + }); + + it("should return {statusChanged: true} if status has changed from down to up", async function () { + statusService.db.getMonitorById = sinon + .stub() + .returns({ status: false, save: sinon.stub() }); + const result = await statusService.updateStatus({ + monitorId: "test", + status: true, + }); + expect(result.statusChanged).to.be.true; + expect(result.monitor.status).to.be.true; + expect(result.prevStatus).to.be.false; + // expect(statusService.insertCheck.calledOnce).to.be.true; + }); + + it("should return {statusChanged: true} if status has changed from up to down", async function () { + statusService.db.getMonitorById = sinon + .stub() + .returns({ status: true, save: sinon.stub() }); + const result = await statusService.updateStatus({ + monitorId: "test", + status: false, + }); + expect(result.statusChanged).to.be.true; + expect(result.monitor.status).to.be.false; + expect(result.prevStatus).to.be.true; + // expect(statusService.insertCheck.calledOnce).to.be.true; + }); + }); + + describe("buildCheck", () => { + it("should build a check object", function () { + const check = statusService.buildCheck({ + monitorId: "test", + type: "test", + status: true, + responseTime: 100, + code: 200, + message: "Test message", + payload: { test: "test" }, + }); + expect(check.monitorId).to.equal("test"); + expect(check.status).to.be.true; + expect(check.statusCode).to.equal(200); + expect(check.responseTime).to.equal(100); + expect(check.message).to.equal("Test message"); + }); + + it("should build a check object for pagespeed type", function () { + const check = statusService.buildCheck({ + monitorId: "test", + type: "pagespeed", + status: true, + responseTime: 100, + code: 200, + message: "Test message", + payload: { + lighthouseResult: { + categories: { + accessibility: { score: 1 }, + "best-practices": { score: 1 }, + performance: { score: 1 }, + seo: { score: 1 }, + }, + audits: { + "cumulative-layout-shift": { score: 1 }, + "speed-index": { score: 1 }, + "first-contentful-paint": { score: 1 }, + "largest-contentful-paint": { score: 1 }, + "total-blocking-time": { score: 1 }, + }, + }, + }, + }); + expect(check.monitorId).to.equal("test"); + expect(check.status).to.be.true; + expect(check.statusCode).to.equal(200); + expect(check.responseTime).to.equal(100); + expect(check.message).to.equal("Test message"); + expect(check.accessibility).to.equal(100); + expect(check.bestPractices).to.equal(100); + expect(check.performance).to.equal(100); + expect(check.seo).to.equal(100); + expect(check.audits).to.deep.equal({ + cls: { score: 1 }, + si: { score: 1 }, + fcp: { score: 1 }, + lcp: { score: 1 }, + tbt: { score: 1 }, + }); + }); + + it("should build a check object for pagespeed type with missing data", function () { + const check = statusService.buildCheck({ + monitorId: "test", + type: "pagespeed", + status: true, + responseTime: 100, + code: 200, + message: "Test message", + payload: { + lighthouseResult: { + categories: {}, + audits: {}, + }, + }, + }); + expect(check.monitorId).to.equal("test"); + expect(check.status).to.be.true; + expect(check.statusCode).to.equal(200); + expect(check.responseTime).to.equal(100); + expect(check.message).to.equal("Test message"); + expect(check.accessibility).to.equal(0); + expect(check.bestPractices).to.equal(0); + expect(check.performance).to.equal(0); + expect(check.seo).to.equal(0); + expect(check.audits).to.deep.equal({ + cls: 0, + si: 0, + fcp: 0, + lcp: 0, + tbt: 0, + }); + }); + + it("should build a check for hardware type", function () { + const check = statusService.buildCheck({ + monitorId: "test", + type: "hardware", + status: true, + responseTime: 100, + code: 200, + message: "Test message", + payload: { data: { cpu: "cpu", memory: "memory", disk: "disk", host: "host" } }, + }); + expect(check.monitorId).to.equal("test"); + expect(check.status).to.be.true; + expect(check.statusCode).to.equal(200); + expect(check.responseTime).to.equal(100); + expect(check.message).to.equal("Test message"); + expect(check.cpu).to.equal("cpu"); + expect(check.memory).to.equal("memory"); + expect(check.disk).to.equal("disk"); + expect(check.host).to.equal("host"); + }); + + it("should build a check for hardware type with missing data", function () { + const check = statusService.buildCheck({ + monitorId: "test", + type: "hardware", + status: true, + responseTime: 100, + code: 200, + message: "Test message", + payload: {}, + }); + expect(check.monitorId).to.equal("test"); + expect(check.status).to.be.true; + expect(check.statusCode).to.equal(200); + expect(check.responseTime).to.equal(100); + expect(check.message).to.equal("Test message"); + expect(check.cpu).to.deep.equal({}); + expect(check.memory).to.deep.equal({}); + expect(check.disk).to.deep.equal({}); + expect(check.host).to.deep.equal({}); + }); + }); + + describe("insertCheck", () => { + it("should log an error if one is thrown", async function () { + const testError = new Error("Test error"); + statusService.db.createCheck = sinon.stub().throws(testError); + try { + await statusService.insertCheck({ monitorId: "test" }); + } catch (error) { + expect(error.message).to.equal(testError.message); + } + expect(statusService.logger.error.calledOnce).to.be.true; + }); + + it("should insert a check into the database", async function () { + await statusService.insertCheck({ monitorId: "test", type: "http" }); + expect(statusService.db.createCheck.calledOnce).to.be.true; + }); + }); +}); diff --git a/server/tests/utils/dataUtils.test.js b/server/tests/utils/dataUtils.test.js new file mode 100755 index 000000000..e39af5a75 --- /dev/null +++ b/server/tests/utils/dataUtils.test.js @@ -0,0 +1,67 @@ +import { NormalizeData, calculatePercentile } from "../../utils/dataUtils.js"; +import sinon from "sinon"; + +describe("NormalizeData", function () { + it("should normalize response times when checks length is greater than 1", function () { + const checks = [ + { responseTime: 20, _doc: { id: 1 } }, + { responseTime: 40, _doc: { id: 2 } }, + { responseTime: 60, _doc: { id: 3 } }, + ]; + const rangeMin = 1; + const rangeMax = 100; + + const result = NormalizeData(checks, rangeMin, rangeMax); + + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(3); + result.forEach((check) => { + expect(check).to.have.property("responseTime").that.is.a("number"); + expect(check).to.have.property("originalResponseTime").that.is.a("number"); + }); + }); + + it("should return checks with original response times when checks length is 1", function () { + const checks = [{ responseTime: 20, _doc: { id: 1 } }]; + const rangeMin = 1; + const rangeMax = 100; + + const result = NormalizeData(checks, rangeMin, rangeMax); + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(1); + expect(result[0]).to.have.property("originalResponseTime", 20); + }); + + it("should handle edge cases with extreme response times", function () { + const checks = [ + { responseTime: 5, _doc: { id: 1 } }, + { responseTime: 95, _doc: { id: 2 } }, + ]; + const rangeMin = 1; + const rangeMax = 100; + + const result = NormalizeData(checks, rangeMin, rangeMax); + + expect(result).to.be.an("array"); + expect(result).to.have.lengthOf(2); + expect(result[0]).to.have.property("responseTime").that.is.at.least(rangeMin); + expect(result[1]).to.have.property("responseTime").that.is.at.most(rangeMax); + }); +}); + +describe("calculatePercentile", function () { + it("should return the lower value when upper is greater than or equal to the length of the sorted array", function () { + const checks = [ + { responseTime: 10 }, + { responseTime: 20 }, + { responseTime: 30 }, + { responseTime: 40 }, + { responseTime: 50 }, + ]; + + const percentile = 100; + const result = calculatePercentile(checks, percentile); + const expected = 50; + expect(result).to.equal(expected); + }); +}); diff --git a/server/tests/utils/imageProcessing.test.js b/server/tests/utils/imageProcessing.test.js new file mode 100755 index 000000000..29e47af6a --- /dev/null +++ b/server/tests/utils/imageProcessing.test.js @@ -0,0 +1,57 @@ +import { expect } from "chai"; +import sinon from "sinon"; +import sharp from "sharp"; +import { GenerateAvatarImage } from "../../utils/imageProcessing.js"; + +describe("imageProcessing - GenerateAvatarImage", function () { + it("should resize the image to 64x64 and return a base64 string", async function () { + const file = { + buffer: Buffer.from("test image buffer"), + }; + + // Stub the sharp function + const toBufferStub = sinon.stub().resolves(Buffer.from("resized image buffer")); + const resizeStub = sinon.stub().returns({ toBuffer: toBufferStub }); + const sharpStub = sinon + .stub(sharp.prototype, "resize") + .returns({ toBuffer: toBufferStub }); + + const result = await GenerateAvatarImage(file); + + // Verify the result + const expected = Buffer.from("resized image buffer").toString("base64"); + expect(result).to.equal(expected); + + // Verify that the sharp function was called with the correct arguments + expect(sharpStub.calledOnceWith({ width: 64, height: 64, fit: "cover" })).to.be.true; + expect(toBufferStub.calledOnce).to.be.true; + + // Restore the stubbed functions + sharpStub.restore(); + }); + + it("should throw an error if resizing fails", async function () { + const file = { + buffer: Buffer.from("test image buffer"), + }; + + // Stub the sharp function to throw an error + const toBufferStub = sinon.stub().rejects(new Error("Resizing failed")); + const resizeStub = sinon.stub().returns({ toBuffer: toBufferStub }); + const sharpStub = sinon + .stub(sharp.prototype, "resize") + .returns({ toBuffer: toBufferStub }); + + try { + await GenerateAvatarImage(file); + // If no error is thrown, fail the test + expect.fail("Expected error to be thrown"); + } catch (error) { + // Verify that the error message is correct + expect(error.message).to.equal("Resizing failed"); + } + + // Restore the stubbed functions + sharpStub.restore(); + }); +}); diff --git a/server/tests/utils/logger.test.js b/server/tests/utils/logger.test.js new file mode 100755 index 000000000..2d5bd82a9 --- /dev/null +++ b/server/tests/utils/logger.test.js @@ -0,0 +1,139 @@ +import sinon from "sinon"; +import logger from "../../utils/logger.js"; +import { Logger } from "../../utils/logger.js"; +import winston from "winston"; + +describe("Logger", function () { + let infoStub, warnStub, errorStub; + + beforeEach(function () { + infoStub = sinon.stub(logger.logger, "info"); + warnStub = sinon.stub(logger.logger, "warn"); + errorStub = sinon.stub(logger.logger, "error"); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe("constructor", function () { + let createLoggerStub; + + beforeEach(function () { + createLoggerStub = sinon.stub(winston, "createLogger"); + }); + + afterEach(function () { + sinon.restore(); + }); + + it("should convert message to JSON string if it is an object", function () { + const logMessage = { key: "value" }; + const expectedMessage = JSON.stringify(logMessage, null, 2); + + createLoggerStub.callsFake((config) => { + const consoleTransport = config.transports[0]; + const logEntry = { + level: "info", + message: logMessage, + timestamp: new Date().toISOString(), + }; + const formattedMessage = consoleTransport.format.transform(logEntry); + expect(formattedMessage).to.include(expectedMessage); + return { log: sinon.spy() }; + }); + + const logger = new Logger(); + logger.logger.info(logMessage); + }); + + it("should convert details to JSON string if it is an object", function () { + const logDetails = { key: "value" }; + const expectedDetails = JSON.stringify(logDetails, null, 2); // Removed .s + + createLoggerStub.callsFake((config) => { + const consoleTransport = config.transports[0]; + const logEntry = { + level: "info", + message: "", // Add empty message since it's required + details: logDetails, + timestamp: new Date().toISOString(), + }; + const formattedMessage = consoleTransport.format.transform(logEntry); + expect(formattedMessage).to.include(expectedDetails); + return { info: sinon.spy() }; // Changed to return info method + }); + + const logger = new Logger(); + logger.logger.info("", { details: logDetails }); // Updated to pass details properly + }); + }); + + describe("info", function () { + it("should log an informational message", function () { + const config = { + message: "Info message", + service: "TestService", + method: "TestMethod", + details: { key: "value" }, + }; + + logger.info(config); + + expect(infoStub.calledOnce).to.be.true; + expect( + infoStub.calledWith(config.message, { + service: config.service, + method: config.method, + details: config.details, + }) + ).to.be.true; + }); + }); + + describe("warn", function () { + it("should log a warning message", function () { + const config = { + message: "Warning message", + service: "TestService", + method: "TestMethod", + details: { key: "value" }, + }; + + logger.warn(config); + + expect(warnStub.calledOnce).to.be.true; + expect( + warnStub.calledWith(config.message, { + service: config.service, + method: config.method, + details: config.details, + }) + ).to.be.true; + }); + }); + + describe("error", function () { + it("should log an error message", function () { + const config = { + message: "Error message", + service: "TestService", + method: "TestMethod", + details: { key: "value" }, + stack: "Error stack trace", + }; + + logger.error(config); + + expect(errorStub.calledOnce).to.be.true; + expect( + errorStub.calledWith(config.message, { + service: config.service, + method: config.method, + details: config.details, + stack: config.stack, + }) + ).to.be.true; + }); + }); +}); diff --git a/server/tests/utils/messages.test.js b/server/tests/utils/messages.test.js new file mode 100755 index 000000000..719af1be0 --- /dev/null +++ b/server/tests/utils/messages.test.js @@ -0,0 +1,29 @@ +import { errorMessages, successMessages } from "../../utils/messages.js"; +describe("Messages", function () { + describe("messages - errorMessages", function () { + it("should have a DB_FIND_MONITOR_BY_ID function", function () { + const monitorId = "12345"; + expect(errorMessages.DB_FIND_MONITOR_BY_ID(monitorId)).to.equal( + `Monitor with id ${monitorId} not found` + ); + }); + + it("should have a DB_DELETE_CHECKS function", function () { + const monitorId = "12345"; + expect(errorMessages.DB_DELETE_CHECKS(monitorId)).to.equal( + `No checks found for monitor with id ${monitorId}` + ); + }); + }); + + describe("messages - successMessages", function () { + it("should have a MONITOR_GET_BY_USER_ID function", function () { + const userId = "12345"; + expect(successMessages.MONITOR_GET_BY_USER_ID(userId)).to.equal( + `Got monitor for ${userId} successfully"` + ); + }); + + // Add more tests for other success messages as needed + }); +}); diff --git a/server/tests/utils/utils.test.js b/server/tests/utils/utils.test.js new file mode 100755 index 000000000..2630bb8e6 --- /dev/null +++ b/server/tests/utils/utils.test.js @@ -0,0 +1,50 @@ +import { ParseBoolean, getTokenFromHeaders } from "../../utils/utils.js"; + +describe("utils - ParseBoolean", function () { + it("should return true", function () { + const result = ParseBoolean("true"); + expect(result).to.be.true; + }); + + it("should return false", function () { + const result = ParseBoolean("false"); + expect(result).to.be.false; + }); + + it("should return false", function () { + const result = ParseBoolean(null); + expect(result).to.be.false; + }); + + it("should return false", function () { + const result = ParseBoolean(undefined); + expect(result).to.be.false; + }); +}); + +describe("utils - getTokenFromHeaders", function () { + it("should throw an error if authorization header is missing", function () { + const headers = {}; + expect(() => getTokenFromHeaders(headers)).to.throw("No auth headers"); + }); + + it("should throw an error if authorization header does not start with Bearer", function () { + const headers = { authorization: "Basic abcdef" }; + expect(() => getTokenFromHeaders(headers)).to.throw("Invalid auth headers"); + }); + + it("should return the token if authorization header is correctly formatted", function () { + const headers = { authorization: "Bearer abcdef" }; + expect(getTokenFromHeaders(headers)).to.equal("abcdef"); + }); + + it("should throw an error if authorization header has more than two parts", function () { + const headers = { authorization: "Bearer abc def" }; + expect(() => getTokenFromHeaders(headers)).to.throw("Invalid auth headers"); + }); + + it("should throw an error if authorization header has less than two parts", function () { + const headers = { authorization: "Bearer" }; + expect(() => getTokenFromHeaders(headers)).to.throw("Invalid auth headers"); + }); +}); diff --git a/server/utils/dataUtils.js b/server/utils/dataUtils.js new file mode 100755 index 000000000..d164b92fd --- /dev/null +++ b/server/utils/dataUtils.js @@ -0,0 +1,116 @@ +const calculatePercentile = (arr, percentile) => { + const sorted = arr.slice().sort((a, b) => a.responseTime - b.responseTime); + const index = (percentile / 100) * (sorted.length - 1); + const lower = Math.floor(index); + const upper = lower + 1; + const weight = index % 1; + if (upper >= sorted.length) return sorted[lower].responseTime; + return sorted[lower].responseTime * (1 - weight) + sorted[upper].responseTime * weight; +}; + +const calculatePercentileUptimeDetails = (arr, percentile) => { + const sorted = arr.slice().sort((a, b) => a.avgResponseTime - b.avgResponseTime); + const index = (percentile / 100) * (sorted.length - 1); + const lower = Math.floor(index); + const upper = lower + 1; + const weight = index % 1; + if (upper >= sorted.length) return sorted[lower].avgResponseTime; + return ( + sorted[lower].avgResponseTime * (1 - weight) + sorted[upper].avgResponseTime * weight + ); +}; + +const NormalizeData = (checks, rangeMin, rangeMax) => { + if (checks.length > 1) { + // Get the 5th and 95th percentile + const min = calculatePercentile(checks, 0); + const max = calculatePercentile(checks, 95); + const normalizedChecks = checks.map((check) => { + const originalResponseTime = check.responseTime; + // Normalize the response time between 1 and 100 + let normalizedResponseTime = + rangeMin + ((check.responseTime - min) * (rangeMax - rangeMin)) / (max - min); + + // Put a floor on the response times so we don't have extreme outliers + // Better visuals + normalizedResponseTime = Math.max( + rangeMin, + Math.min(rangeMax, normalizedResponseTime) + ); + return { + ...check, + responseTime: normalizedResponseTime, + originalResponseTime: originalResponseTime, + }; + }); + + return normalizedChecks; + } else { + return checks.map((check) => { + return { ...check, originalResponseTime: check.responseTime }; + }); + } +}; +const NormalizeDataUptimeDetails = (checks, rangeMin, rangeMax) => { + if (checks.length > 1) { + // Get the 5th and 95th percentile + const min = calculatePercentileUptimeDetails(checks, 0); + const max = calculatePercentileUptimeDetails(checks, 95); + + const normalizedChecks = checks.map((check) => { + const originalResponseTime = check.avgResponseTime; + // Normalize the response time between 1 and 100 + let normalizedResponseTime = + rangeMin + ((check.avgResponseTime - min) * (rangeMax - rangeMin)) / (max - min); + + // Put a floor on the response times so we don't have extreme outliers + // Better visuals + normalizedResponseTime = Math.max( + rangeMin, + Math.min(rangeMax, normalizedResponseTime) + ); + return { + ...check, + avgResponseTime: normalizedResponseTime, + originalAvgResponseTime: originalResponseTime, + }; + }); + + return normalizedChecks; + } else { + return checks.map((check) => { + return { ...check, originalResponseTime: check.responseTime }; + }); + } +}; + +const safelyParseFloat = (value, defaultValue = 0) => { + if (value === null || typeof value === "undefined") { + return defaultValue; + } + const stringValue = String(value).trim(); + + if (typeof value === "number" && !isNaN(value)) { + return value; + } + + if (stringValue === "") { + return defaultValue; + } + + const parsedValue = parseFloat(stringValue); + + if (isNaN(parsedValue) || !isFinite(parsedValue)) { + return defaultValue; + } + + return parsedValue; +}; + +export { + safelyParseFloat, + calculatePercentile, + NormalizeData, + calculatePercentileUptimeDetails, + NormalizeDataUptimeDetails, +}; diff --git a/server/utils/demoMonitors.json b/server/utils/demoMonitors.json new file mode 100755 index 000000000..3efea8d0e --- /dev/null +++ b/server/utils/demoMonitors.json @@ -0,0 +1,22 @@ +[ + { + "name": "Google", + "url": "https://www.google.com" + }, + { + "name": "Facebook", + "url": "https://www.facebook.com" + }, + { + "name": "Yahoo", + "url": "https://www.yahoo.com" + }, + { + "name": "Amazon", + "url": "https://www.amazon.com" + }, + { + "name": "Apple", + "url": "https://www.apple.com" + } +] diff --git a/server/utils/demoMonitorsOld.json b/server/utils/demoMonitorsOld.json new file mode 100755 index 000000000..ffce7d226 --- /dev/null +++ b/server/utils/demoMonitorsOld.json @@ -0,0 +1,1271 @@ +[ + { + "name": "0to255", + "url": "https://0to255.com" + }, + { + "name": "10015.io", + "url": "https://10015.io" + }, + { + "name": "3DIcons", + "url": "https://3dicons.co" + }, + { + "name": "About.me", + "url": "https://about.me" + }, + { + "name": "Alias", + "url": "https://alias.co" + }, + { + "name": "All About Berlin", + "url": "https://allaboutberlin.com" + }, + { + "name": "All Acronyms", + "url": "https://allacronyms.com" + }, + { + "name": "All You Can Read ", + "url": "https://allyoucanread.com" + }, + { + "name": "AllTrails", + "url": "https://alltrails.com" + }, + { + "name": "Anotepad", + "url": "https://anotepad.com" + }, + { + "name": "AnswerSocrates", + "url": "https://answersocrates.com" + }, + { + "name": "AnswerThePublic ", + "url": "https://answerthepublic.com" + }, + { + "name": "Apollo ", + "url": "https://apollo.io" + }, + { + "name": "ArrayList", + "url": "https://arraylist.org" + }, + { + "name": "Ask Difference", + "url": "https://askdifference.com" + }, + { + "name": "Audd.io", + "url": "https://audd.io" + }, + { + "name": "Audiocheck", + "url": "https://audiocheck.net" + }, + { + "name": "Audionautix", + "url": "https://audionautix.com" + }, + { + "name": "Authentic Jobs", + "url": "https://authenticjobs.com" + }, + { + "name": "Behind the Name", + "url": "https://behindthename.com" + }, + { + "name": "Bilim Terimleri", + "url": "https://terimler.org" + }, + { + "name": "BitBof", + "url": "https://bitbof.com" + }, + { + "name": "Blank Page", + "url": "https://blank.page" + }, + { + "name": "Bonanza", + "url": "https://bonanza.com" + }, + { + "name": "BookCrossing", + "url": "https://bookcrossing.com" + }, + { + "name": "Browse AI", + "url": "https://browse.ai" + }, + { + "name": "Bubbl.us", + "url": "https://bubbl.us" + }, + { + "name": "Business Model Toolbox", + "url": "https://bmtoolbox.net" + }, + { + "name": "ByClickDownloader", + "url": "https://byclickdownloader.com" + }, + { + "name": "Calligraphr", + "url": "https://calligraphr.com" + }, + { + "name": "CertificateClaim", + "url": "https://certificateclaim.com" + }, + { + "name": "Chosic", + "url": "https://chosic.com" + }, + { + "name": "ClipDrop", + "url": "https://clipdrop.co" + }, + { + "name": "CloudConvert", + "url": "https://cloudconvert.com" + }, + { + "name": "CodingFont", + "url": "https://codingfont.com" + }, + { + "name": "Color Hunt", + "url": "https://colorhunt.co" + }, + { + "name": "ColorHexa", + "url": "https://colorhexa.com" + }, + { + "name": "Conversion-Tool", + "url": "https://conversion-tool.com" + }, + { + "name": "Cool Startup Jobs", + "url": "https://coolstartupjobs.com" + }, + { + "name": "Coroflot", + "url": "https://coroflot.com" + }, + { + "name": "Corrupt-a-File", + "url": "https://corrupt-a-file.net" + }, + { + "name": "Couchsurfing", + "url": "https://couchsurfing.com" + }, + { + "name": "Countries Been", + "url": "https://countriesbeen.com" + }, + { + "name": "Country Code", + "url": "https://countrycode.org" + }, + { + "name": "Creately", + "url": "https://creately.com" + }, + { + "name": "Creately ", + "url": "https://creately.com" + }, + { + "name": "Crossfade.io", + "url": "https://crossfade.io" + }, + { + "name": "Crunchbase", + "url": "https://crunchbase.com" + }, + { + "name": "CVmkr", + "url": "https://cvwizard.com" + }, + { + "name": "Daily Remote", + "url": "https://dailyremote.com" + }, + { + "name": "David Li", + "url": "https://david.li" + }, + { + "name": "DemandHunt", + "url": "https://demandhunt.com" + }, + { + "name": "Designify", + "url": "https://designify.com" + }, + { + "name": "Diff Checker", + "url": "https://diffchecker.com" + }, + { + "name": "DifferenceBetween.info", + "url": "https://differencebetween.info" + }, + { + "name": "Digital Glossary", + "url": "https://digital-glossary.com" + }, + { + "name": "Dimensions", + "url": "https://dimensions.com" + }, + { + "name": "Discoverify Music", + "url": "https://discoverifymusic.com" + }, + { + "name": "discu.eu", + "url": "https://discu.eu" + }, + { + "name": "Do It Yourself", + "url": "https://doityourself.com" + }, + { + "name": "draw.io", + "url": "https://drawio.com" + }, + { + "name": "Drumeo", + "url": "https://drumeo.com" + }, + { + "name": "Dummies", + "url": "https://dummies.com" + }, + { + "name": "Easel.ly", + "url": "https://easel.ly" + }, + { + "name": "Educalingo", + "url": "https://educalingo.com" + }, + { + "name": "Emoji Combos", + "url": "https://emojicombos.com" + }, + { + "name": "EquityBee", + "url": "https://equitybee.com" + }, + { + "name": "EquityZen", + "url": "https://equityzen.com" + }, + { + "name": "Escape Room Tips", + "url": "https://escaperoomtips.com" + }, + { + "name": "Every Noise", + "url": "https://everynoise.com" + }, + { + "name": "Every Time Zone", + "url": "https://everytimezone.com" + }, + { + "name": "Excalideck", + "url": "https://excalideck.com" + }, + { + "name": "Excalidraw", + "url": "https://excalidraw.com" + }, + { + "name": "Extract pics", + "url": "https://extract.pics" + }, + { + "name": "EZGIF", + "url": "https://ezgif.com" + }, + { + "name": "FactSlides", + "url": "https://factslides.com" + }, + { + "name": "FIGR ", + "url": "https://figr.app" + }, + { + "name": "Fine Dictionary", + "url": "https://finedictionary.com" + }, + { + "name": "Fiverr", + "url": "https://fiverr.com" + }, + { + "name": "Fix It Club", + "url": "https://fixitclub.com" + }, + { + "name": "Flightradar24", + "url": "https://flightradar24.com" + }, + { + "name": "FlowCV ", + "url": "https://flowcv.com" + }, + { + "name": "Font Squirrel", + "url": "https://fontsquirrel.com" + }, + { + "name": "FontAwesome", + "url": "https://fontawesome.com" + }, + { + "name": "Fontello ", + "url": "https://fontello.com" + }, + { + "name": "Form to Chatbot", + "url": "https://formtochatbot.com" + }, + { + "name": "Founder Resources", + "url": "https://founderresources.io" + }, + { + "name": "Franz", + "url": "https://meetfranz.com" + }, + { + "name": "Fraze It", + "url": "https://fraze.it" + }, + { + "name": "Freecycle", + "url": "https://freecycle.org" + }, + { + "name": "FreeType", + "url": "https://freetype.org" + }, + { + "name": "FutureM", + "url": "https://futureme.org" + }, + { + "name": "Generated.Photos", + "url": "https://generated.photos" + }, + { + "name": "Get Human", + "url": "https://gethuman.com" + }, + { + "name": "Go Bento", + "url": "https://gobento.com" + }, + { + "name": "Good CV", + "url": "https://goodcv.com" + }, + { + "name": "Grammar Monster", + "url": "https://grammar-monster.com" + }, + { + "name": "Grammar Book", + "url": "https://grammarbook.com" + }, + { + "name": "Gummy Search", + "url": "https://gummysearch.com" + }, + { + "name": "Gumroad", + "url": "https://gumroad.com" + }, + { + "name": "HealthIcons", + "url": "https://healthicons.org" + }, + { + "name": "HexColor", + "url": "https://hexcolor.co" + }, + { + "name": "Hidden Life Radio", + "url": "https://hiddenliferadio.com" + }, + { + "name": "Hired", + "url": "https://lhh.com" + }, + { + "name": "Honey", + "url": "https://joinhoney.com" + }, + { + "name": "HowStuffWorks", + "url": "https://howstuffworks.com" + }, + { + "name": "HugeIcons Pro", + "url": "https://hugeicons.com" + }, + { + "name": "Humble Bundle", + "url": "https://humblebundle.com" + }, + { + "name": "I Have No TV", + "url": "https://ihavenotv.com" + }, + { + "name": "I Miss the Office", + "url": "https://imisstheoffice.eu" + }, + { + "name": "IcoMoon", + "url": "https://icomoon.io" + }, + { + "name": "Iconfinder", + "url": "https://iconfinder.com" + }, + { + "name": "Icon Packs", + "url": "https://iconpacks.net" + }, + { + "name": "Iconshock", + "url": "https://iconshock.com" + }, + { + "name": "Iconz Design", + "url": "https://iconz.design" + }, + { + "name": "iFixit", + "url": "https://ifixit.com" + }, + { + "name": "IFTTT", + "url": "https://ifttt.com" + }, + { + "name": "Illlustrations", + "url": "https://illlustrations.co" + }, + { + "name": "Illustration Kit", + "url": "https://illustrationkit.com" + }, + { + "name": "IMSDB", + "url": "https://imsdb.com" + }, + { + "name": "Incompetech", + "url": "https://incompetech.com" + }, + { + "name": "Incredibox", + "url": "https://incredibox.com" + }, + { + "name": "InnerBod", + "url": "https://innerbody.com" + }, + { + "name": "Instructables", + "url": "https://instructables.com" + }, + { + "name": "Integromat", + "url": "https://make.com" + }, + { + "name": "Investopedia", + "url": "https://investopedia.com" + }, + { + "name": "Japanese Wiki Corpus", + "url": "https://japanesewiki.com" + }, + { + "name": "Jitter.Video", + "url": "https://jitter.video" + }, + { + "name": "Jobspresso", + "url": "https://jobspresso.co" + }, + { + "name": "JPEG-Optimizer", + "url": "https://jpeg-optimizer.com" + }, + { + "name": "JS Remotely", + "url": "https://jsremotely.com" + }, + { + "name": "JScreenFix", + "url": "https://jscreenfix.com" + }, + { + "name": "JSON Resume", + "url": "https://jsonresume.io" + }, + { + "name": "Just Join", + "url": "https://justjoin.it" + }, + { + "name": "Just the Recipe", + "url": "https://justtherecipe.com" + }, + { + "name": "JustRemote", + "url": "https://justremote.co" + }, + { + "name": "JustWatch", + "url": "https://justwatch.com" + }, + { + "name": "Kanopy", + "url": "https://kanopy.com" + }, + { + "name": "Kassellabs", + "url": "https://kassellabs.io" + }, + { + "name": "Key Differences", + "url": "https://keydifferences.com" + }, + { + "name": "Keybase", + "url": "https://keybase.io" + }, + { + "name": "KeyValues", + "url": "https://keyvalues.com" + }, + { + "name": "KHInsider", + "url": "https://khinsider.com" + }, + { + "name": "Killed by Google", + "url": "https://killedbygoogle.com" + }, + { + "name": "Kimovil", + "url": "https://kimovil.com" + }, + { + "name": "Lalal.ai", + "url": "https://www.lalal.ai" + }, + { + "name": "Learn Anything", + "url": "https://learn-anything.xyz" + }, + { + "name": "LendingTree", + "url": "https://lendingtree.com" + }, + { + "name": "Lightyear.fm", + "url": "https://lightyear.fm" + }, + { + "name": "LittleSis", + "url": "https://littlesis.org" + }, + { + "name": "Looria", + "url": "https://looria.com" + }, + { + "name": "Lucidchart", + "url": "https://lucidchart.com" + }, + { + "name": "Lunar", + "url": "https://lunar.fyi" + }, + { + "name": "Manuals Lib", + "url": "https://manualslib.com" + }, + { + "name": "Map Crunch", + "url": "https://mapcrunch.com" + }, + { + "name": "Masterworks", + "url": "https://masterworks.com" + }, + { + "name": "MediaFire", + "url": "https://mediafire.com" + }, + { + "name": "Mixlr", + "url": "https://mixlr.com" + }, + { + "name": "Moises AI", + "url": "https://moises.ai" + }, + { + "name": "Money", + "url": "https://money.com" + }, + { + "name": "Mountain Project", + "url": "https://mountainproject.com" + }, + { + "name": "Movie Map", + "url": "https://movie-map.com" + }, + { + "name": "Movie Sounds", + "url": "https://movie-sounds.org" + }, + { + "name": "MP3Cut", + "url": "https://mp3cut.net" + }, + { + "name": "Murmel", + "url": "https://murmel.social" + }, + { + "name": "Muscle Wiki", + "url": "https://musclewiki.com" + }, + { + "name": "Music-Map", + "url": "https://music-map.com" + }, + { + "name": "MusicTheory.net", + "url": "https://musictheory.net" + }, + { + "name": "MyFonts", + "url": "https://myfonts.com" + }, + { + "name": "MyFridgeFood", + "url": "https://myfridgefood.com" + }, + { + "name": "Nameberry", + "url": "https://nameberry.com" + }, + { + "name": "Namechk", + "url": "https://namechk.com" + }, + { + "name": "Ncase", + "url": "https://ncase.me" + }, + { + "name": "News in Levels", + "url": "https://newsinlevels.com" + }, + { + "name": "Noisli", + "url": "https://noisli.com" + }, + { + "name": "Notes.io", + "url": "https://notes.io" + }, + { + "name": "Novoresume", + "url": "https://novoresume.com" + }, + { + "name": "Ocoya", + "url": "https://ocoya.com" + }, + { + "name": "Old Computers Museum", + "url": "https://oldcomputers.net" + }, + { + "name": "Online Tone Generator", + "url": "https://onlinetonegenerator.com" + }, + { + "name": "Online-Convert", + "url": "https://online-convert.com" + }, + { + "name": "OnlineConversion", + "url": "https://onlineconversion.com" + }, + { + "name": "Online OCR", + "url": "https://onlineocr.net" + }, + { + "name": "OpenWeatherMap", + "url": "https://openweathermap.org" + }, + { + "name": "OrgPad", + "url": "https://orgpad.com" + }, + { + "name": "Passport Index", + "url": "https://passportindex.org" + }, + { + "name": "PDF Candy", + "url": "https://pdfcandy.com" + }, + { + "name": "PDF2DOC", + "url": "https://pdf2doc.com" + }, + { + "name": "PDFescape", + "url": "https://pdfescape.com" + }, + { + "name": "PfpMaker", + "url": "https://pfpmaker.com" + }, + { + "name": "PIDGI Wiki ", + "url": "https://pidgi.net" + }, + { + "name": "PimEyes", + "url": "https://pimeyes.com" + }, + { + "name": "Pipl ", + "url": "https://pipl.com" + }, + { + "name": "PixelBazaar", + "url": "https://pixelbazaar.com" + }, + { + "name": "PixelPaper", + "url": "https://pixelpaper.io" + }, + { + "name": "Ponly", + "url": "https://ponly.com" + }, + { + "name": "PowerToFly", + "url": "https://powertofly.com" + }, + { + "name": "Pretzel Rocks", + "url": "https://pretzel.rocks" + }, + { + "name": "PrintIt", + "url": "https://printit.work" + }, + { + "name": "Prismatext", + "url": "https://prismatext.com" + }, + { + "name": "Puffin Maps", + "url": "https://puffinmaps.com" + }, + { + "name": "Puzzle Loop ", + "url": "https://puzzle-loop.com" + }, + { + "name": "QuoteMaster", + "url": "https://quotemaster.org" + }, + { + "name": "Radio Garden", + "url": "https://radio.garden" + }, + { + "name": "Radiooooo", + "url": "https://radiooooo.com" + }, + { + "name": "Radiosondy", + "url": "https://radiosondy.info" + }, + { + "name": "Rainy Mood", + "url": "https://rainymood.com" + }, + { + "name": "Random Street View", + "url": "https://randomstreetview.com" + }, + { + "name": "Rap4Ever", + "url": "https://rap4all.com" + }, + { + "name": "RareFilm", + "url": "https://rarefilm.net" + }, + { + "name": "Rattibha", + "url": "https://rattibha.com" + }, + { + "name": "Reddit List ", + "url": "https://redditlist.com" + }, + { + "name": "RedditSearch.io", + "url": "https://redditsearch.io" + }, + { + "name": "Reelgood", + "url": "https://reelgood.com" + }, + { + "name": "Reface", + "url": "https://reface.ai" + }, + { + "name": "Rejected.us", + "url": "https://rejected.us" + }, + { + "name": "Relanote", + "url": "https://relanote.com" + }, + { + "name": "Remote Leaf", + "url": "https://remoteleaf.com" + }, + { + "name": "Remote OK", + "url": "https://remoteok.com" + }, + { + "name": "Remote Starter Kit ", + "url": "https://remotestarterkit.com" + }, + { + "name": "Remote.co", + "url": "https://remote.co" + }, + { + "name": "Remote Base ", + "url": "https://remotebase.com" + }, + { + "name": "Remote Bear", + "url": "https://remotebear.io" + }, + { + "name": "Remove.bg", + "url": "https://remove.bg" + }, + { + "name": "Respresso", + "url": "https://respresso.io" + }, + { + "name": "Reveddit", + "url": "https://reveddit.com" + }, + { + "name": "Rhymer", + "url": "https://rhymer.com" + }, + { + "name": "RhymeZone", + "url": "https://rhymezone.com" + }, + { + "name": "Ribbet", + "url": "https://ribbet.com" + }, + { + "name": "Roadmap.sh", + "url": "https://roadmap.sh" + }, + { + "name": "Roadtrippers", + "url": "https://roadtrippers.com" + }, + { + "name": "RxResu.me", + "url": "https://rxresu.me" + }, + { + "name": "SchemeColor", + "url": "https://schemecolor.com" + }, + { + "name": "Screenshot.Guru", + "url": "https://screenshot.guru" + }, + { + "name": "SeatGuru", + "url": "https://seatguru.com" + }, + { + "name": "Sessions", + "url": "https://sessions.us" + }, + { + "name": "Shottr", + "url": "https://shottr.cc" + }, + { + "name": "Signature Maker", + "url": "https://signature-maker.net" + }, + { + "name": "Skip The Drive", + "url": "https://skipthedrive.com" + }, + { + "name": "Slowby", + "url": "https://slowby.travel" + }, + { + "name": "Small World", + "url": "https://smallworld.kiwi" + }, + { + "name": "SmallPDF", + "url": "https://smallpdf.com" + }, + { + "name": "Social Image Maker", + "url": "https://socialimagemaker.io" + }, + { + "name": "Social Sizes", + "url": "https://socialsizes.io" + }, + { + "name": "SoundLove", + "url": "https://soundlove.se" + }, + { + "name": "Spline", + "url": "https://spline.design" + }, + { + "name": "Starkey Comics", + "url": "https://starkeycomics.com" + }, + + { + "name": "Statista", + "url": "https://statista.com" + }, + { + "name": "Stolen Camera Finder", + "url": "https://stolencamerafinder.com" + }, + { + "name": "Strobe.Cool", + "url": "https://strobe.cool" + }, + { + "name": "Sumo", + "url": "https://sumo.app" + }, + { + "name": "SuperMeme AI", + "url": "https://supermeme.ai" + }, + { + "name": "Synthesia", + "url": "https://synthesia.io" + }, + { + "name": "TablerIcons", + "url": "https://tablericons.com" + }, + { + "name": "Tango", + "url": "https://tango.us" + }, + { + "name": "TasteDive", + "url": "https://tastedive.com" + }, + { + "name": "TechSpecs", + "url": "https://techspecs.io" + }, + { + "name": "Teoria", + "url": "https://teoria.com" + }, + { + "name": "Text Faces", + "url": "https://textfac.es" + }, + { + "name": "The Balance Money", + "url": "https://thebalancemoney.com" + }, + { + "name": "The Punctuation Guide", + "url": "https://thepunctuationguide.com" + }, + { + "name": "This to That", + "url": "https://thistothat.com" + }, + { + "name": "This vs That", + "url": "https://thisvsthat.io" + }, + { + "name": "ThreadReaderApp ", + "url": "https://threadreaderapp.com" + }, + { + "name": "Thumbly", + "url": "https://tokee.ai" + }, + { + "name": "Tiii.me", + "url": "https://tiii.me" + }, + { + "name": "TikTok Video Downloader", + "url": "https://ttvdl.com" + }, + { + "name": "Time and Date", + "url": "https://timeanddate.com" + }, + { + "name": "Time.is", + "url": "https://time.is" + }, + { + "name": "Title Case", + "url": "https://titlecase.com" + }, + { + "name": "Toaster Central", + "url": "https://toastercentral.com" + }, + { + "name": "Tongue-Twister ", + "url": "https://tongue-twister.net" + }, + { + "name": "TradingView", + "url": "https://tradingview.com" + }, + { + "name": "Transparent Textures", + "url": "https://transparenttextures.com" + }, + { + "name": "Tubi TV", + "url": "https://tubitv.com" + }, + { + "name": "Tunefind", + "url": "https://tunefind.com" + }, + { + "name": "TuneMyMusic", + "url": "https://tunemymusic.com" + }, + { + "name": "Tweepsmap", + "url": "https://fedica.com" + }, + { + "name": "Two Peas and Their Pod", + "url": "https://twopeasandtheirpod.com" + }, + { + "name": "Typatone", + "url": "https://typatone.com" + }, + { + "name": "Under Glass", + "url": "https://underglass.io" + }, + { + "name": "UniCorner", + "url": "https://unicorner.news" + }, + { + "name": "Unita", + "url": "https://unita.co" + }, + { + "name": "UnitConverters", + "url": "https://unitconverters.net" + }, + { + "name": "Unreadit", + "url": "https://unreadit.com" + }, + { + "name": "Unscreen", + "url": "https://unscreen.com" + }, + { + "name": "UnTools ", + "url": "https://untools.co" + }, + { + "name": "Upwork", + "url": "https://upwork.com" + }, + { + "name": "UTF8 Icons", + "url": "https://utf8icons.com" + }, + { + "name": "Vector Magic", + "url": "https://vectormagic.com" + }, + { + "name": "Virtual Vacation", + "url": "https://virtualvacation.us" + }, + { + "name": "Virtual Vocations", + "url": "https://virtualvocations.com" + }, + { + "name": "Visiwig", + "url": "https://visiwig.com" + }, + { + "name": "Visual CV", + "url": "https://visualcv.com" + }, + { + "name": "Vocus.io", + "url": "https://vocus.io" + }, + { + "name": "Voscreen", + "url": "https://voscreen.com" + }, + { + "name": "Wanderprep", + "url": "https://wanderprep.com" + }, + { + "name": "Warmshowers", + "url": "https:/warmshowers.org" + }, + { + "name": "Watch Documentaries", + "url": "https://watchdocumentaries.com" + }, + { + "name": "We Work Remotely", + "url": "https://weworkremotely.com" + }, + { + "name": "Web2PDFConvert", + "url": "https://web2pdfconvert.com" + }, + { + "name": "Welcome to My Garden", + "url": "https://welcometomygarden.org" + }, + { + "name": "When2meet ", + "url": "https://when2meet.com" + }, + { + "name": "Where's George", + "url": "https://wheresgeorge.com" + }, + { + "name": "Where's Willy", + "url": "https://whereswilly.com" + }, + { + "name": "WikiHow", + "url": "https://wikihow.com" + }, + { + "name": "Windy", + "url": "https://www.windy.com" + }, + { + "name": "WonderHowTo", + "url": "https://wonderhowto.com" + }, + { + "name": "Working Nomads", + "url": "https://workingnomads.com" + }, + { + "name": "Wormhole", + "url": "https://wormhole.app" + }, + { + "name": "Y Combinator Jobs", + "url": "https://ycombinator.com" + }, + { + "name": "Yes Promo", + "url": "https://yespromo.me" + }, + { + "name": "YouGlish", + "url": "https://youglish.com" + }, + { + "name": "Zamzar", + "url": "https://zamzar.com" + }, + { + "name": "Zippyshare", + "url": "https://zippyshare.com" + }, + { + "name": "Zoom Earth", + "url": "https://zoom.earth" + }, + { + "name": "Zoom.it", + "url": "https://zoom.it" + } +] diff --git a/server/utils/imageProcessing.js b/server/utils/imageProcessing.js new file mode 100755 index 000000000..470a5c27e --- /dev/null +++ b/server/utils/imageProcessing.js @@ -0,0 +1,25 @@ +import sharp from "sharp"; +/** + * Generates a 64 * 64 pixel image from a given image + * @param {} file + */ +const GenerateAvatarImage = async (file) => { + try { + // Resize to target 64 * 64 + let resizedImageBuffer = await sharp(file.buffer) + .resize({ + width: 64, + height: 64, + fit: "cover", + }) + .toBuffer(); + + //Get b64 string + const base64Image = resizedImageBuffer.toString("base64"); + return base64Image; + } catch (error) { + throw error; + } +}; + +export { GenerateAvatarImage }; diff --git a/server/utils/logger.js b/server/utils/logger.js new file mode 100755 index 000000000..801fee6fe --- /dev/null +++ b/server/utils/logger.js @@ -0,0 +1,115 @@ +import { createLogger, format, transports } from "winston"; + +class Logger { + constructor() { + const consoleFormat = format.printf( + ({ level, message, service, method, details, timestamp, stack }) => { + if (message instanceof Object) { + message = JSON.stringify(message, null, 2); + } + + if (details instanceof Object) { + details = JSON.stringify(details, null, 2); + } + let msg = `${timestamp} ${level}:`; + service && (msg += ` [${service}]`); + method && (msg += `(${method})`); + message && (msg += ` ${message}`); + details && (msg += ` (details: ${details})`); + + if (typeof stack !== "undefined") { + const stackTrace = stack + ?.split("\n") + .slice(1) // Remove first line (error message) + .map((line) => { + const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/); + if (match) { + return { + function: match[1], + file: match[2], + line: parseInt(match[3]), + column: parseInt(match[4]), + }; + } + return line.trim(); + }); + stack && (msg += ` (stack: ${JSON.stringify(stackTrace, null, 2)})`); + } + + return msg; + } + ); + + this.logger = createLogger({ + level: "info", + format: format.combine(format.timestamp()), + transports: [ + new transports.Console({ + format: format.combine( + format.colorize(), + format.prettyPrint(), + format.json(), + consoleFormat + ), + }), + new transports.File({ + format: format.combine(format.json()), + filename: "app.log", + }), + ], + }); + } + /** + * Logs an informational message. + * @param {Object} config - The configuration object. + * @param {string} config.message - The message to log. + * @param {string} config.service - The service name. + * @param {string} config.method - The method name. + * @param {Object} config.details - Additional details. + */ + info(config) { + this.logger.info(config.message, { + service: config.service, + method: config.method, + details: config.details, + }); + } + + /** + * Logs a warning message. + * @param {Object} config - The configuration object. + * @param {string} config.message - The message to log. + * @param {string} config.service - The service name. + * @param {string} config.method - The method name. + * @param {Object} config.details - Additional details. + */ + warn(config) { + this.logger.warn(config.message, { + service: config.service, + method: config.method, + details: config.details, + }); + } + + /** + * Logs an error message. + * @param {Object} config - The configuration object. + * @param {string} config.message - The message to log. + * @param {string} config.service - The service name. + * @param {string} config.method - The method name. + * @param {Object} config.details - Additional details. + */ + error(config) { + this.logger.error(config.message, { + service: config.service, + method: config.method, + details: config.details, + stack: config.stack, + }); + } +} + +const logger = new Logger(); +export { Logger }; + +export default logger; diff --git a/server/utils/utils.js b/server/utils/utils.js new file mode 100755 index 000000000..57d181886 --- /dev/null +++ b/server/utils/utils.js @@ -0,0 +1,30 @@ +/** + * Converts a request body parameter to a boolean. + * @param {string | boolean} value + * @returns {boolean} + */ +const ParseBoolean = (value) => { + if (value === true || value === "true") { + return true; + } else if ( + value === false || + value === "false" || + value === null || + value === undefined + ) { + return false; + } +}; + +const getTokenFromHeaders = (headers) => { + const authorizationHeader = headers.authorization; + if (!authorizationHeader) throw new Error("No auth headers"); + + const parts = authorizationHeader.split(" "); + if (parts.length !== 2 || parts[0] !== "Bearer") + throw new Error("Invalid auth headers"); + + return parts[1]; +}; + +export { ParseBoolean, getTokenFromHeaders }; diff --git a/server/validation/joi.js b/server/validation/joi.js new file mode 100755 index 000000000..c588eee81 --- /dev/null +++ b/server/validation/joi.js @@ -0,0 +1,655 @@ +import joi from "joi"; + +//**************************************** +// Custom Validators +//**************************************** + +const roleValidatior = (role) => (value, helpers) => { + const hasRole = role.some((role) => value.includes(role)); + if (!hasRole) { + throw new joi.ValidationError( + `You do not have the required authorization. Required roles: ${role.join(", ")}` + ); + } + return value; +}; + +//**************************************** +// Auth +//**************************************** + +const passwordPattern = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!?@#$%^&*()\-_=+[\]{};:'",.<>~`|\\/])[A-Za-z0-9!?@#$%^&*()\-_=+[\]{};:'",.<>~`|\\/]+$/; + +const loginValidation = joi.object({ + email: joi + .string() + .email() + .required() + .custom((value, helpers) => { + const lowercasedValue = value.toLowerCase(); + if (value !== lowercasedValue) { + return helpers.message("Email must be in lowercase"); + } + return lowercasedValue; + }), + password: joi.string().min(8).required().pattern(passwordPattern), +}); +const nameValidation = joi + .string() + .trim() + .max(50) + .pattern(/^(?=.*[\p{L}\p{Sc}])[\p{L}\p{Sc}\s']+$/u) + .messages({ + "string.empty": "Name is required", + "string.max": "Name must be less than 50 characters", + "string.pattern.base": + "Name must contain at least 1 letter or currency symbol and only allow letters, spaces, apostrophes, and currency symbols", + }); + +const registrationBodyValidation = joi.object({ + firstName: nameValidation.required(), + lastName: nameValidation.required(), + email: joi + .string() + .email() + .required() + .custom((value, helpers) => { + const lowercasedValue = value.toLowerCase(); + if (value !== lowercasedValue) { + return helpers.message("Email must be in lowercase"); + } + return lowercasedValue; + }), + password: joi.string().min(8).required().pattern(passwordPattern), + profileImage: joi.any(), + role: joi + .array() + .items(joi.string().valid("superadmin", "admin", "user", "demo")) + .min(1) + .required(), + teamId: joi.string().allow("").required(), + inviteToken: joi.string().allow("").required(), +}); + +const editUserParamValidation = joi.object({ + userId: joi.string().required(), +}); + +const editUserBodyValidation = joi.object({ + firstName: nameValidation.required(), + lastName: nameValidation.required(), + profileImage: joi.any(), + newPassword: joi.string().min(8).pattern(passwordPattern), + password: joi.string().min(8).pattern(passwordPattern), + deleteProfileImage: joi.boolean(), + role: joi.array(), +}); + +const recoveryValidation = joi.object({ + email: joi + .string() + .email({ tlds: { allow: false } }) + .required(), +}); + +const recoveryTokenValidation = joi.object({ + recoveryToken: joi.string().required(), +}); + +const newPasswordValidation = joi.object({ + recoveryToken: joi.string().required(), + password: joi.string().min(8).required().pattern(passwordPattern), + confirm: joi.string(), +}); + +const deleteUserParamValidation = joi.object({ + email: joi.string().email().required(), +}); + +const inviteRoleValidation = joi.object({ + roles: joi.custom(roleValidatior(["admin", "superadmin"])).required(), +}); + +const inviteBodyValidation = joi.object({ + email: joi.string().trim().email().required().messages({ + "string.empty": "Email is required", + "string.email": "Must be a valid email address", + }), + role: joi.array().required(), + teamId: joi.string().required(), +}); + +const inviteVerificationBodyValidation = joi.object({ + token: joi.string().required(), +}); + +//**************************************** +// Monitors +//**************************************** + +const getMonitorByIdParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const getMonitorByIdQueryValidation = joi.object({ + status: joi.boolean(), + sortOrder: joi.string().valid("asc", "desc"), + limit: joi.number(), + dateRange: joi.string().valid("hour", "day", "week", "month", "all"), + numToDisplay: joi.number(), + normalize: joi.boolean(), +}); + +const getMonitorsByTeamIdParamValidation = joi.object({ + teamId: joi.string().required(), +}); + +const getMonitorsByTeamIdQueryValidation = joi.object({ + limit: joi.number(), + type: joi + .alternatives() + .try( + joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port"), + joi + .array() + .items( + joi.string().valid("http", "ping", "pagespeed", "docker", "hardware", "port") + ) + ), + page: joi.number(), + rowsPerPage: joi.number(), + filter: joi.string(), + field: joi.string(), + order: joi.string().valid("asc", "desc"), +}); + +const getMonitorStatsByIdParamValidation = joi.object({ + monitorId: joi.string().required(), +}); +const getMonitorStatsByIdQueryValidation = joi.object({ + status: joi.string(), + limit: joi.number(), + sortOrder: joi.string().valid("asc", "desc"), + dateRange: joi.string().valid("hour", "day", "week", "month", "all"), + numToDisplay: joi.number(), + normalize: joi.boolean(), +}); + +const getCertificateParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const createMonitorBodyValidation = joi.object({ + _id: joi.string(), + userId: joi.string().required(), + teamId: joi.string().required(), + name: joi.string().required(), + description: joi.string().required(), + type: joi.string().required(), + url: joi.string().required(), + port: joi.number(), + isActive: joi.boolean(), + interval: joi.number(), + thresholds: joi.object().keys({ + usage_cpu: joi.number(), + usage_memory: joi.number(), + usage_disk: joi.number(), + usage_temperature: joi.number(), + }), + notifications: joi.array().items(joi.object()), + secret: joi.string(), + jsonPath: joi.string().allow(""), + expectedValue: joi.string().allow(""), + matchMethod: joi.string(), +}); + +const createMonitorsBodyValidation = joi.array().items(createMonitorBodyValidation); + +const editMonitorBodyValidation = joi.object({ + name: joi.string(), + description: joi.string(), + interval: joi.number(), + notifications: joi.array().items(joi.object()), + secret: joi.string(), + jsonPath: joi.string().allow(""), + expectedValue: joi.string().allow(""), + matchMethod: joi.string(), + thresholds: joi.object().keys({ + usage_cpu: joi.number(), + usage_memory: joi.number(), + usage_disk: joi.number(), + usage_temperature: joi.number(), + }), +}); + +const pauseMonitorParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const getMonitorURLByQueryValidation = joi.object({ + monitorURL: joi.string().uri().required(), +}); + +const getHardwareDetailsByIdParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const getHardwareDetailsByIdQueryValidation = joi.object({ + dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), +}); + +//**************************************** +// Alerts +//**************************************** + +const createAlertParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const createAlertBodyValidation = joi.object({ + checkId: joi.string().required(), + monitorId: joi.string().required(), + userId: joi.string().required(), + status: joi.boolean(), + message: joi.string(), + notifiedStatus: joi.boolean(), + acknowledgeStatus: joi.boolean(), +}); + +const getAlertsByUserIdParamValidation = joi.object({ + userId: joi.string().required(), +}); + +const getAlertsByMonitorIdParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const getAlertByIdParamValidation = joi.object({ + alertId: joi.string().required(), +}); + +const editAlertParamValidation = joi.object({ + alertId: joi.string().required(), +}); + +const editAlertBodyValidation = joi.object({ + status: joi.boolean(), + message: joi.string(), + notifiedStatus: joi.boolean(), + acknowledgeStatus: joi.boolean(), +}); + +const deleteAlertParamValidation = joi.object({ + alertId: joi.string().required(), +}); + +//**************************************** +// Checks +//**************************************** + +const createCheckParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const createCheckBodyValidation = joi.object({ + monitorId: joi.string().required(), + status: joi.boolean().required(), + responseTime: joi.number().required(), + statusCode: joi.number().required(), + message: joi.string().required(), +}); + +const getChecksParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const getChecksQueryValidation = joi.object({ + type: joi.string().valid("http", "ping", "pagespeed", "hardware", "docker", "port", "distributed_http", "distributed_test"), + sortOrder: joi.string().valid("asc", "desc"), + limit: joi.number(), + dateRange: joi.string().valid("recent", "hour", "day", "week", "month", "all"), + filter: joi.string().valid("all", "down", "resolve"), + page: joi.number(), + rowsPerPage: joi.number(), + status: joi.boolean(), +}); + +const getTeamChecksParamValidation = joi.object({ + teamId: joi.string().required(), +}); + +const getTeamChecksQueryValidation = joi.object({ + sortOrder: joi.string().valid("asc", "desc"), + limit: joi.number(), + dateRange: joi.string().valid("hour", "day", "week", "month", "all"), + filter: joi.string().valid("all", "down", "resolve"), + page: joi.number(), + rowsPerPage: joi.number(), + status: joi.boolean(), +}); + +const deleteChecksParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const deleteChecksByTeamIdParamValidation = joi.object({ + teamId: joi.string().required(), +}); + +const updateChecksTTLBodyValidation = joi.object({ + ttl: joi.number().required(), +}); + +//**************************************** +// PageSpeedCheckValidation +//**************************************** + +const getPageSpeedCheckParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +//Validation schema for the monitorId parameter +const createPageSpeedCheckParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +//Validation schema for the monitorId body +const createPageSpeedCheckBodyValidation = joi.object({ + url: joi.string().required(), +}); + +const deletePageSpeedCheckParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +//**************************************** +// MaintenanceWindowValidation +//**************************************** + +const createMaintenanceWindowBodyValidation = joi.object({ + monitors: joi.array().items(joi.string()).required(), + name: joi.string().required(), + active: joi.boolean(), + start: joi.date().required(), + end: joi.date().required(), + repeat: joi.number().required(), + expiry: joi.date(), +}); + +const getMaintenanceWindowByIdParamValidation = joi.object({ + id: joi.string().required(), +}); + +const getMaintenanceWindowsByTeamIdQueryValidation = joi.object({ + active: joi.boolean(), + page: joi.number(), + rowsPerPage: joi.number(), + field: joi.string(), + order: joi.string().valid("asc", "desc"), +}); + +const getMaintenanceWindowsByMonitorIdParamValidation = joi.object({ + monitorId: joi.string().required(), +}); + +const deleteMaintenanceWindowByIdParamValidation = joi.object({ + id: joi.string().required(), +}); + +const editMaintenanceWindowByIdParamValidation = joi.object({ + id: joi.string().required(), +}); + +const editMaintenanceByIdWindowBodyValidation = joi.object({ + active: joi.boolean(), + name: joi.string(), + repeat: joi.number(), + start: joi.date(), + end: joi.date(), + expiry: joi.date(), + monitors: joi.array(), +}); + +//**************************************** +// SettingsValidation +//**************************************** +const updateAppSettingsBodyValidation = joi.object({ + apiBaseUrl: joi.string().allow(""), + logLevel: joi.string().valid("debug", "none", "error", "warn").allow(""), + clientHost: joi.string().allow(""), + dbType: joi.string().allow(""), + dbConnectionString: joi.string().allow(""), + redisHost: joi.string().allow(""), + redisPort: joi.number().allow(null, ""), + redisUrl: joi.string().allow(""), + jwtTTL: joi.string().allow(""), + pagespeedApiKey: joi.string().allow(""), + language: joi.string().allow(""), + systemEmailHost: joi.string().allow(""), + systemEmailPort: joi.number().allow(""), + systemEmailAddress: joi.string().allow(""), + systemEmailPassword: joi.string().allow(""), +}); + +//**************************************** +// Status Page Validation +//**************************************** + +const getStatusPageParamValidation = joi.object({ + url: joi.string().required(), +}); + +const getStatusPageQueryValidation = joi.object({ + type: joi.string().valid("uptime", "distributed").required(), + timeFrame: joi.number().optional(), +}); + +const createStatusPageBodyValidation = joi.object({ + userId: joi.string().required(), + teamId: joi.string().required(), + type: joi.string().valid("uptime", "distributed").required(), + companyName: joi.string().required(), + url: joi + .string() + .pattern(/^[a-zA-Z0-9_-]+$/) // Only allow alphanumeric, underscore, and hyphen + .required() + .messages({ + "string.pattern.base": + "URL can only contain letters, numbers, underscores, and hyphens", + }), + timezone: joi.string().optional(), + color: joi.string().optional(), + monitors: joi + .array() + .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/)) + .required() + .messages({ + "string.pattern.base": "Must be a valid monitor ID", + "array.base": "Monitors must be an array", + "array.empty": "At least one monitor is required", + "any.required": "Monitors are required", + }), + subMonitors: joi + .array() + .items(joi.string().pattern(/^[0-9a-fA-F]{24}$/)) + .optional(), + deleteSubmonitors: joi.boolean().optional(), + isPublished: joi.boolean(), + showCharts: joi.boolean().optional(), + showUptimePercentage: joi.boolean(), +}); + +const imageValidation = joi + .object({ + fieldname: joi.string().required(), + originalname: joi.string().required(), + encoding: joi.string().required(), + mimetype: joi + .string() + .valid("image/jpeg", "image/png", "image/jpg") + .required() + .messages({ + "string.valid": "File must be a valid image (jpeg, jpg, or png)", + }), + size: joi.number().max(3145728).required().messages({ + "number.max": "File size must be less than 3MB", + }), + buffer: joi.binary().required(), + destination: joi.string(), + filename: joi.string(), + path: joi.string(), + }) + .messages({ + "any.required": "Image file is required", + }); + +const webhookConfigValidation = joi + .object({ + webhookUrl: joi + .string() + .uri() + .when("$platform", { + switch: [ + { + is: "telegram", + then: joi.optional(), + }, + { + is: "discord", + then: joi.required().messages({ + "string.empty": "Discord webhook URL is required", + "string.uri": "Discord webhook URL must be a valid URL", + "any.required": "Discord webhook URL is required", + }), + }, + { + is: "slack", + then: joi.required().messages({ + "string.empty": "Slack webhook URL is required", + "string.uri": "Slack webhook URL must be a valid URL", + "any.required": "Slack webhook URL is required", + }), + }, + ], + }), + botToken: joi.string().when("$platform", { + is: "telegram", + then: joi.required().messages({ + "string.empty": "Telegram bot token is required", + "any.required": "Telegram bot token is required", + }), + otherwise: joi.optional(), + }), + chatId: joi.string().when("$platform", { + is: "telegram", + then: joi.required().messages({ + "string.empty": "Telegram chat ID is required", + "any.required": "Telegram chat ID is required", + }), + otherwise: joi.optional(), + }), + }) + .required(); + +const triggerNotificationBodyValidation = joi.object({ + monitorId: joi.string().required().messages({ + "string.empty": "Monitor ID is required", + "any.required": "Monitor ID is required", + }), + type: joi.string().valid("webhook").required().messages({ + "string.empty": "Notification type is required", + "any.required": "Notification type is required", + "any.only": "Notification type must be webhook", + }), + platform: joi.string().valid("telegram", "discord", "slack").required().messages({ + "string.empty": "Platform type is required", + "any.required": "Platform type is required", + "any.only": "Platform must be telegram, discord, or slack", + }), + config: webhookConfigValidation.required().messages({ + "any.required": "Webhook configuration is required", + }), +}); + +//**************************************** +// Announcetment Page Validation +//**************************************** + +const createAnnouncementValidation = joi.object({ + title: joi.string().required().messages({ + 'string.empty': 'Title cannot be empty', + 'any.required': 'Title is required', + }), + message: joi.string().required().messages({ + 'string.empty': 'Message cannot be empty', + 'any.required': 'Message is required', + }), + userId: joi.string().required(), + }); + + +export { + roleValidatior, + loginValidation, + registrationBodyValidation, + recoveryValidation, + recoveryTokenValidation, + newPasswordValidation, + inviteRoleValidation, + inviteBodyValidation, + inviteVerificationBodyValidation, + createMonitorBodyValidation, + createMonitorsBodyValidation, + getMonitorByIdParamValidation, + getMonitorByIdQueryValidation, + getMonitorsByTeamIdParamValidation, + getMonitorsByTeamIdQueryValidation, + getMonitorStatsByIdParamValidation, + getMonitorStatsByIdQueryValidation, + getHardwareDetailsByIdParamValidation, + getHardwareDetailsByIdQueryValidation, + getCertificateParamValidation, + editMonitorBodyValidation, + pauseMonitorParamValidation, + getMonitorURLByQueryValidation, + editUserParamValidation, + editUserBodyValidation, + createAlertParamValidation, + createAlertBodyValidation, + getAlertsByUserIdParamValidation, + getAlertsByMonitorIdParamValidation, + getAlertByIdParamValidation, + editAlertParamValidation, + editAlertBodyValidation, + deleteAlertParamValidation, + createCheckParamValidation, + createCheckBodyValidation, + getChecksParamValidation, + getChecksQueryValidation, + getTeamChecksParamValidation, + getTeamChecksQueryValidation, + deleteChecksParamValidation, + deleteChecksByTeamIdParamValidation, + updateChecksTTLBodyValidation, + deleteUserParamValidation, + getPageSpeedCheckParamValidation, + createPageSpeedCheckParamValidation, + deletePageSpeedCheckParamValidation, + createPageSpeedCheckBodyValidation, + createMaintenanceWindowBodyValidation, + getMaintenanceWindowByIdParamValidation, + getMaintenanceWindowsByTeamIdQueryValidation, + getMaintenanceWindowsByMonitorIdParamValidation, + deleteMaintenanceWindowByIdParamValidation, + editMaintenanceWindowByIdParamValidation, + editMaintenanceByIdWindowBodyValidation, + updateAppSettingsBodyValidation, + createStatusPageBodyValidation, + getStatusPageParamValidation, + getStatusPageQueryValidation, + imageValidation, + triggerNotificationBodyValidation, + webhookConfigValidation, + createAnnouncementValidation +}; diff --git a/uptime.sh b/uptime.sh deleted file mode 100755 index 956d5e33c..000000000 --- a/uptime.sh +++ /dev/null @@ -1,117 +0,0 @@ -default_server_base_url="http://localhost:5000/api/v1" -default_client_host="http://localhost:5173" -default_jwt_secret="my_secret" -default_db_type="MongoDB" -default_db_connection_string="mongodb://mongodb:27017/uptime_db" -default_redis_host="redis" -default_redis_port=6379 -default_token_ttl="99d" - -default_system_email_host="smtp.gmail.com" -default_system_email_port=465 - -echo "Welcome to the Uptime Monitor Setup Script! \n" -echo - -echo "Configuring client" -printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' '*' -echo - -read -p "Enter the API Base URL (press enter for default) [$default_server_base_url]: " input_url -input_url="${input_url:-$default_server_base_url}" - - -echo "API base url: $input_url" -echo - -echo "Writing to ./Docker/client.env" -echo - -echo "VITE_APP_API_BASE_URL=\"$input_url\"" > ./Client/.env - -echo "Configuring server" -printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' '*' -echo - -read -p "Enter the Client Host [$default_client_host]: " client_host -client_host="${client_host:-$default_client_host}" -echo "Client Host: $client_host" -echo - -read -p "Enter your JWT secret [$default_jwt_secret]: " jwt_secret -jwt_secret="${jwt_secret:-$default_jwt_secret}" -echo "JWT Secret: $jwt_secret" -echo - -read -p "Enter your DB type [$default_db_type]: " db_type -db_type="${db_type:-$default_db_type}" -echo "DB Type: $db_type" -echo - -read -p "Enter your DB connection string [$default_db_connection_string]: " db_connection_string -db_connection_string="${db_connection_string:-$default_db_connection_string}" -echo "DB connection string: $db_connection_string" -echo - -read -p "Enter your Redis Host [$default_redis_host]: " redis_host -redis_host="${redis_host:-$default_redis_host}" -echo "Redis Host: $redis_host" -echo - -read -p "Enter your Redis Port [$default_redis_port]: " redis_port -redis_port="${redis_port:-$default_redis_port}" -echo "Redis Port: $redis_port" -echo - -read -p "Enter your system email host [$default_system_email_host]: " system_email_host -system_email_host="${system_email_host:-$default_system_email_host}" -echo "System email host: $system_email_host" -echo - -read -p "Enter your system email port [$default_system_email_port]: " system_email_port -system_email_port="${system_email_port:-$default_system_email_port}" -echo "System email port: $system_email_port" -echo - -read -p "Enter your system email address: " system_email_address -echo "System email address: $system_email_address" -echo - -read -p "Enter your system email password: " system_email_password -echo "System email password: $system_email_password" -echo - - - -read -p "Enter your Token TTL [$default_token_ttl]: " token_ttl -token_ttl="${token_ttl:-$default_token_ttl}" -echo "Token TTL: $token_ttl" -echo - -read -p "Enter your Pagespeed API key: " pagespeed_api_key -echo "Pagespeed API key: $pagespeed_api_key" -echo - -echo "Writing to ./Docker/server.env" -echo - -{ - echo "CLIENT_HOST=\"$client_host\"" - echo "JWT_SECRET=\"$jwt_secret\"" - echo "DB_TYPE=\"$db_type\"" - echo "DB_CONNECTION_STRING=\"$db_connection_string\"" - echo "REDIS_HOST=\"$redis_host\"" - echo "REDIS_PORT=$redis_port" - echo "SYSTEM_EMAIL_HOST=\"$system_email_host\"" - echo "SYSTEM_EMAIL_PORT=$system_email_port" - echo "SYSTEM_EMAIL_ADDRESS=\"$system_email_address\"" - echo "SYSTEM_EMAIL_PASSWORD=\"$system_email_password\"" - echo "TOKEN_TTL=\"$token_ttl\"" - echo "PAGESPEED_API_KEY=\"$pagespeed_api_key\"" -} > ./Docker/server.env - -cd ./Docker -source ./build_images.sh - -cd ./Docker -docker-compose up \ No newline at end of file