Merge pull request #3715 from Cincinnatus101010/feat/status-page-custom-domain
feat: custom domain support for published status pages
This commit is contained in:
31 files changed
+815
-69
No files matched your search
@@ -31,6 +31,7 @@ export const useStatusPageForm = ({
|
||||
const defaults: StatusPageFormData = {
|
||||
companyName: data?.companyName || "",
|
||||
url: data?.url || generateDefaultUrl(),
|
||||
customDomain: data?.customDomain ?? null,
|
||||
timezone: resolveTimezone(data?.timezone),
|
||||
type: data?.type || ["uptime"],
|
||||
color: data?.color || "#13715B",
|
||||
|
||||
@@ -151,6 +151,11 @@ const CreateStatusPage = () => {
|
||||
fd.append("isPublished", String(data.isPublished));
|
||||
if (data.companyName) fd.append("companyName", data.companyName);
|
||||
if (data.url) fd.append("url", data.url);
|
||||
if (data.customDomain !== undefined && data.customDomain !== null) {
|
||||
fd.append("customDomain", data.customDomain);
|
||||
} else {
|
||||
fd.append("customDomain", "");
|
||||
}
|
||||
if (data.timezone) fd.append("timezone", data.timezone);
|
||||
if (data.color) fd.append("color", data.color);
|
||||
fd.append("showCharts", String(data.showCharts));
|
||||
@@ -284,6 +289,27 @@ const CreateStatusPage = () => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="customDomain"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
fieldLabel={t(
|
||||
"pages.statusPages.form.basicInfo.option.customDomain.label"
|
||||
)}
|
||||
placeholder={t(
|
||||
"pages.statusPages.form.basicInfo.option.customDomain.placeholder"
|
||||
)}
|
||||
helperText={
|
||||
fieldState.error?.message ||
|
||||
t("pages.statusPages.form.basicInfo.option.customDomain.helper")
|
||||
}
|
||||
error={!!fieldState.error}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Settings, ExternalLink } from "lucide-react";
|
||||
import { useTheme } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
|
||||
import { type StatusPage } from "@/Types/StatusPage";
|
||||
import { getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
|
||||
|
||||
interface HeaderStatusPageControlsProps {
|
||||
isAdmin: boolean;
|
||||
@@ -50,7 +51,7 @@ export const HeaderStatusPageControls = ({
|
||||
<Typography
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`,
|
||||
getStatusPagePublicUrl(statusPage),
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
type StatusPageResponse,
|
||||
type StatusPageTheme,
|
||||
} from "@/Types/StatusPage";
|
||||
import {
|
||||
buildStatusPageApiPath,
|
||||
getStatusPagePublicUrl,
|
||||
isCustomDomainHost,
|
||||
} from "@/Utils/statusPageUrl";
|
||||
import { HeaderStatusPageControls } from "@/Pages/StatusPage/Status/Components/HeaderStatusPageControls";
|
||||
import { StatusPageThemeProvider } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider";
|
||||
import {
|
||||
@@ -67,9 +72,14 @@ const StatusPageView = () => {
|
||||
const isAdmin = useIsAdmin();
|
||||
const location = useLocation();
|
||||
|
||||
const isPublic = location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
|
||||
const onCustomDomainHost = isCustomDomainHost();
|
||||
const isPublic =
|
||||
onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX);
|
||||
|
||||
const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null;
|
||||
const apiUrl = buildStatusPageApiPath({
|
||||
url,
|
||||
useCustomDomain: onCustomDomainHost,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useGet<StatusPageResponse>(
|
||||
apiUrl,
|
||||
@@ -134,7 +144,7 @@ const StatusPageView = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const publicUrl = `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
|
||||
const publicUrl = getStatusPagePublicUrl(statusPage);
|
||||
return (
|
||||
<BasePage
|
||||
loading={isLoading}
|
||||
|
||||
@@ -11,7 +11,9 @@ import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
|
||||
import { getStatusPagePublicPath, getStatusPagePublicUrl } from "@/Utils/statusPageUrl";
|
||||
|
||||
import { type StatusPage } from "@/Types/StatusPage";
|
||||
|
||||
interface StatusPagesTableProps {
|
||||
data: StatusPage[];
|
||||
@@ -53,8 +55,7 @@ export const StatusPagesTable = ({
|
||||
const handleUrlClick = (e: React.MouseEvent, row: StatusPage) => {
|
||||
if (row.isPublished) {
|
||||
e.stopPropagation();
|
||||
const url = `${PUBLIC_STATUS_PAGE_PREFIX}/${row.url}`;
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
window.open(getStatusPagePublicUrl(row), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,7 +71,9 @@ export const StatusPagesTable = ({
|
||||
content: t("pages.statusPages.table.headers.url"),
|
||||
render: (row) => {
|
||||
const content = row.isPublished
|
||||
? `/${row.url}`
|
||||
? row.customDomain
|
||||
? getStatusPagePublicPath(row)
|
||||
: `/${row.url}`
|
||||
: t("pages.statusPages.table.unpublished");
|
||||
return (
|
||||
<Stack
|
||||
|
||||
@@ -54,8 +54,24 @@ import Logs from "@/Pages/Logs";
|
||||
import { ProtectedRoute, RoleProtectedRoute } from "@/Components/routing/RouteProtected";
|
||||
|
||||
import CreateMonitor from "@/Pages/CreateMonitor";
|
||||
import { isCustomDomainHost } from "@/Utils/statusPageUrl";
|
||||
|
||||
const Routes = () => {
|
||||
if (isCustomDomainHost()) {
|
||||
return (
|
||||
<LibRoutes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<Status />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<Status />}
|
||||
/>
|
||||
</LibRoutes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LibRoutes>
|
||||
<Route
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface StatusPage {
|
||||
type: MonitorDisplayType[];
|
||||
companyName: string;
|
||||
url: string;
|
||||
customDomain?: string | null;
|
||||
timezone?: string;
|
||||
color: string;
|
||||
monitors: string[];
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
|
||||
|
||||
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST as string | undefined;
|
||||
|
||||
const getClientOriginProtocol = (): string => {
|
||||
const source =
|
||||
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
|
||||
if (!source) {
|
||||
return "https:";
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(source).protocol;
|
||||
} catch {
|
||||
return "https:";
|
||||
}
|
||||
};
|
||||
|
||||
const buildCustomDomainOrigin = (customDomain: string): string =>
|
||||
`${getClientOriginProtocol()}//${customDomain}`;
|
||||
|
||||
export const getAppHostname = (): string | null => {
|
||||
const source =
|
||||
CLIENT_HOST || (typeof window !== "undefined" ? window.location.origin : undefined);
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(source).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const isCustomDomainHost = (): boolean => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const appHostname = getAppHostname();
|
||||
if (!appHostname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.location.hostname.toLowerCase() !== appHostname;
|
||||
};
|
||||
|
||||
export const getStatusPagePublicPath = (
|
||||
statusPage: Pick<StatusPage, "url" | "customDomain">
|
||||
): string => {
|
||||
if (statusPage.customDomain) {
|
||||
return buildCustomDomainOrigin(statusPage.customDomain);
|
||||
}
|
||||
|
||||
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
|
||||
};
|
||||
|
||||
export const getStatusPagePublicUrl = (
|
||||
statusPage: Pick<StatusPage, "url" | "customDomain">
|
||||
): string => {
|
||||
if (statusPage.customDomain) {
|
||||
return buildCustomDomainOrigin(statusPage.customDomain);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.origin}${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
|
||||
}
|
||||
|
||||
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
|
||||
};
|
||||
|
||||
export const buildStatusPageApiPath = (options: {
|
||||
url?: string;
|
||||
useCustomDomain?: boolean;
|
||||
}): string | null => {
|
||||
const query = "type=uptime&type=infrastructure";
|
||||
|
||||
if (options.useCustomDomain && typeof window !== "undefined") {
|
||||
const domain = encodeURIComponent(window.location.hostname);
|
||||
return `/status-page/resolve?domain=${domain}&${query}`;
|
||||
}
|
||||
|
||||
if (options.url) {
|
||||
return `/status-page/${options.url}?${query}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -9,6 +9,23 @@ import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage"
|
||||
// field definitions.
|
||||
export const statusPageStepRegistry = z.registry<{ step: number }>();
|
||||
|
||||
const statusPageHostnameRegex =
|
||||
/^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
|
||||
|
||||
const normalizeCustomDomainInput = (raw: string | null): string | null => {
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withoutScheme = trimmed.replace(/^https?:\/\//, "");
|
||||
return withoutScheme.split("/")[0]?.split(":")[0] ?? null;
|
||||
};
|
||||
|
||||
export const statusPageSchema = z.object({
|
||||
companyName: z
|
||||
.string()
|
||||
@@ -22,6 +39,12 @@ export const statusPageSchema = z.object({
|
||||
/^[a-z0-9-]+$/,
|
||||
"URL can only contain lowercase letters, numbers, and hyphens"
|
||||
),
|
||||
customDomain: z
|
||||
.union([z.string(), z.null()])
|
||||
.transform(normalizeCustomDomainInput)
|
||||
.refine((domain) => domain === null || statusPageHostnameRegex.test(domain), {
|
||||
message: "Enter a valid domain name (e.g. status.example.com)",
|
||||
}),
|
||||
timezone: z.string().optional(),
|
||||
type: z
|
||||
.array(z.enum(["uptime", "infrastructure"]))
|
||||
|
||||
@@ -1509,6 +1509,11 @@
|
||||
"url": {
|
||||
"label": "Your status page address",
|
||||
"placeholder": "my-status-page"
|
||||
},
|
||||
"customDomain": {
|
||||
"label": "Custom domain",
|
||||
"placeholder": "status.example.com",
|
||||
"helper": "Optional. Point a DNS record to this Checkmate instance to serve the status page at your own domain."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10448,6 +10448,17 @@
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]+$"
|
||||
},
|
||||
"customDomain": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$"
|
||||
},
|
||||
{
|
||||
"nullable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -10651,6 +10662,17 @@
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]+$"
|
||||
},
|
||||
"customDomain": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^(?=.{1,253}$)([a-zA-Z0-9_](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,63}$"
|
||||
},
|
||||
{
|
||||
"nullable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -10920,6 +10942,102 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/status-page/resolve": {
|
||||
"get": {
|
||||
"tags": ["status-page"],
|
||||
"summary": "Resolve a published status page by custom domain",
|
||||
"parameters": [
|
||||
{
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["uptime", "infrastructure"]
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": ["uptime", "infrastructure"]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": true,
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"required": false,
|
||||
"name": "timeFrame",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": false,
|
||||
"name": "domain",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": ["success", "msg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Status page not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"data": {
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": ["success", "msg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/status-page/{url}": {
|
||||
"get": {
|
||||
"tags": ["status-page"],
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { registry } from "../registry.js";
|
||||
import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
|
||||
import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/api/validation/statusPageValidation.js";
|
||||
import {
|
||||
createStatusPageBodyValidation,
|
||||
getStatusPageParamValidation,
|
||||
getStatusPageQueryValidation,
|
||||
resolveStatusPageQueryValidation,
|
||||
} from "@/api/validation/statusPageValidation.js";
|
||||
|
||||
const tags = ["status-page"];
|
||||
|
||||
@@ -33,6 +38,15 @@ registry.registerPath({
|
||||
responses: { "200": okUnknown, ...standardErrors },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/status-page/resolve",
|
||||
tags,
|
||||
summary: "Resolve a published status page by custom domain",
|
||||
request: { query: resolveStatusPageQueryValidation },
|
||||
responses: { "200": okUnknown, "404": { description: "Status page not found" }, "500": standardErrors["500"] },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/status-page/{url}",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getStatusPageParamValidation,
|
||||
getStatusPageQueryValidation,
|
||||
imageValidation,
|
||||
resolveStatusPageQueryValidation,
|
||||
} from "@/api/validation/statusPageValidation.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { requireTeamId, requireUserId } from "@/api/controllers/controllerUtils.js";
|
||||
@@ -12,6 +13,8 @@ import { IStatusPageService } from "@/domain/status-pages/status-page.service.js
|
||||
import { IMonitorsRepository } from "@/domain/monitors/monitor.repository.interface.js";
|
||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { NormalizeData } from "@/utils/dataUtils.js";
|
||||
import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js";
|
||||
import type { StatusPage } from "@/domain/status-pages/status-page.type.js";
|
||||
|
||||
const SERVICE_NAME = "statusPageController";
|
||||
|
||||
@@ -20,6 +23,7 @@ export interface IStatusPageController {
|
||||
createStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
updateStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
getStatusPageByUrl(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
resolveStatusPageByDomain(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
getStatusPagesByTeamId(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
deleteStatusPage(req: Request, res: Response, next: NextFunction): Promise<Response | void>;
|
||||
}
|
||||
@@ -39,6 +43,41 @@ class StatusPageController implements IStatusPageController {
|
||||
return StatusPageController.SERVICE_NAME;
|
||||
}
|
||||
|
||||
private buildStatusPagePayload = async (statusPage: StatusPage, req: Request) => {
|
||||
if (!statusPage.isPublished) {
|
||||
const teamId = requireTeamId(req?.user?.teamId);
|
||||
if (statusPage.teamId !== teamId) {
|
||||
throw new AppError({ message: "Forbidden", status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await this.settingsService.getDBSettings();
|
||||
const showURL = settings.showURL;
|
||||
|
||||
const monitors = await this.monitorsRepository.findByIds(statusPage.monitors);
|
||||
const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index]));
|
||||
const sortedMonitors = [...monitors].sort((a, b) => {
|
||||
const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const normalizedMonitors = sortedMonitors.map((monitor) => {
|
||||
const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100);
|
||||
if (!showURL) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { url, port, secret, notifications, ...rest } = monitor;
|
||||
return { ...rest, checks: normalizedChecks };
|
||||
}
|
||||
return { ...monitor, checks: normalizedChecks };
|
||||
});
|
||||
|
||||
return {
|
||||
statusPage,
|
||||
monitors: normalizedMonitors,
|
||||
};
|
||||
};
|
||||
|
||||
createStatusPage = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
createStatusPageBodyValidation.parse(req.body);
|
||||
@@ -96,47 +135,44 @@ class StatusPageController implements IStatusPageController {
|
||||
}
|
||||
|
||||
const statusPage = await this.statusPageService.getStatusPageByUrl(req.params.url as string);
|
||||
const data = await this.buildStatusPagePayload(statusPage, req);
|
||||
|
||||
if (!statusPage.isPublished) {
|
||||
const teamId = requireTeamId(req?.user?.teamId);
|
||||
if (statusPage.teamId !== teamId) {
|
||||
throw new AppError({ message: "Forbidden", status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const settings = await this.settingsService.getDBSettings();
|
||||
const showURL = settings.showURL;
|
||||
|
||||
const monitors = await this.monitorsRepository.findByIds(statusPage.monitors);
|
||||
// Sort monitors according to the order in statusPage.monitors
|
||||
const monitorOrder = new Map(statusPage.monitors.map((id, index) => [id, index]));
|
||||
const sortedMonitors = [...monitors].sort((a, b) => {
|
||||
const orderA = monitorOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = monitorOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
const normalizedMonitors = sortedMonitors.map((monitor) => {
|
||||
const normalizedChecks = NormalizeData(monitor.recentChecks, 10, 100);
|
||||
if (!showURL) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { url, port, secret, notifications, ...rest } = monitor;
|
||||
return { ...rest, checks: normalizedChecks };
|
||||
}
|
||||
return { ...monitor, checks: normalizedChecks };
|
||||
});
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Status page retrieved successfully",
|
||||
data: {
|
||||
statusPage,
|
||||
monitors: normalizedMonitors,
|
||||
},
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
resolveStatusPageByDomain = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
resolveStatusPageQueryValidation.parse(req.query);
|
||||
|
||||
const domain = resolveStatusPageDomainFromRequest(req.hostname, req.query.domain as string | undefined);
|
||||
if (!domain) {
|
||||
throw new AppError({ message: "Domain is required", status: 400 });
|
||||
}
|
||||
|
||||
const statusPage = await this.statusPageService.getStatusPageByCustomDomain(domain);
|
||||
if (!statusPage.isPublished) {
|
||||
throw new AppError({ message: "Status page not found", status: 404 });
|
||||
}
|
||||
|
||||
const data = await this.buildStatusPagePayload(statusPage, req);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: "Status page retrieved successfully",
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
getStatusPagesByTeamId = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
@@ -7,8 +7,7 @@ import { AppError } from "@/utils/AppError.js";
|
||||
const multerErrorStatus = (error: MulterError): number => (error.code === "LIMIT_FILE_SIZE" ? 413 : 400);
|
||||
|
||||
const handleErrors = (error: unknown, req: Request, res: Response, _next: NextFunction) => {
|
||||
const status =
|
||||
error instanceof MulterError ? multerErrorStatus(error) : error instanceof AppError ? error.status || 500 : 500;
|
||||
const status = error instanceof MulterError ? multerErrorStatus(error) : error instanceof AppError ? error.status || 500 : 500;
|
||||
const message = error instanceof AppError ? error.message : error instanceof Error ? error.message : "Server error";
|
||||
const service = error instanceof AppError ? error.service : error instanceof MulterError ? "uploadMiddleware" : "unknownService";
|
||||
const method = error instanceof AppError ? error.method : "unknownMethod";
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { CorsOptions } from "cors";
|
||||
import type { IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
|
||||
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
|
||||
|
||||
const CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
type CustomDomainCorsCacheEntry = {
|
||||
allowed: boolean;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const getCustomDomainCorsCacheTtlMs = (allowed: boolean): number =>
|
||||
allowed ? CUSTOM_DOMAIN_CORS_POSITIVE_CACHE_TTL_MS : CUSTOM_DOMAIN_CORS_NEGATIVE_CACHE_TTL_MS;
|
||||
|
||||
export const createStatusPageCorsOrigin = (clientHost: string, statusPagesRepository: IStatusPagesRepository): CorsOptions["origin"] => {
|
||||
const cache = new Map<string, CustomDomainCorsCacheEntry>();
|
||||
|
||||
const cacheOriginResult = (customDomain: string, allowed: boolean) => {
|
||||
cache.set(customDomain, {
|
||||
allowed,
|
||||
expiresAt: Date.now() + getCustomDomainCorsCacheTtlMs(allowed),
|
||||
});
|
||||
};
|
||||
|
||||
return (origin, callback) => {
|
||||
if (!origin) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (origin === clientHost) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let customDomain: string | null = null;
|
||||
try {
|
||||
customDomain = normalizeStatusPageDomain(new URL(origin).hostname);
|
||||
} catch {
|
||||
callback(null, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!customDomain) {
|
||||
callback(null, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = cache.get(customDomain);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
callback(null, cached.allowed ? origin : false);
|
||||
return;
|
||||
}
|
||||
|
||||
statusPagesRepository
|
||||
.findByCustomDomain(customDomain)
|
||||
.then((statusPage) => {
|
||||
const allowed = statusPage.isPublished;
|
||||
cacheOriginResult(customDomain, allowed);
|
||||
callback(null, allowed ? origin : false);
|
||||
})
|
||||
.catch(() => {
|
||||
cacheOriginResult(customDomain, false);
|
||||
callback(null, false);
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
const PUBLIC_STATUS_PAGE_API_PREFIX = "/api/v1/status-page";
|
||||
|
||||
export const isPublicStatusPageApiPath = (method: string, path: string): boolean => {
|
||||
if (!path.startsWith(PUBLIC_STATUS_PAGE_API_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (method !== "GET" && method !== "OPTIONS") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subPath = path.slice(PUBLIC_STATUS_PAGE_API_PREFIX.length).split("?")[0] ?? "";
|
||||
const slug = subPath.replace(/^\//, "");
|
||||
|
||||
if (slug === "resolve") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!slug || slug === "team" || slug.includes("/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -30,7 +30,13 @@ class AuthRoutes {
|
||||
this.router.patch("/users/:userId/password", verifyJWT, isAllowed(["superadmin"]), this.authController.editUserPasswordById);
|
||||
this.router.delete("/users/:userId", verifyJWT, isAllowed(["admin", "superadmin"]), this.authController.deleteUserById);
|
||||
|
||||
this.router.patch("/user", verifyJWT, imageUpload.single("profileImage"), isAllowed(["admin", "superadmin", "user"]), this.authController.editUser);
|
||||
this.router.patch(
|
||||
"/user",
|
||||
verifyJWT,
|
||||
imageUpload.single("profileImage"),
|
||||
isAllowed(["admin", "superadmin", "user"]),
|
||||
this.authController.editUser
|
||||
);
|
||||
this.router.delete("/user", verifyJWT, isAllowed(["admin", "superadmin", "user"]), this.authController.deleteUser);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class StatusPageRoutes {
|
||||
this.router.post("/", imageUpload.single("logo"), verifyJWT, this.statusPageController.createStatusPage);
|
||||
this.router.put("/:id", imageUpload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage);
|
||||
|
||||
this.router.get("/resolve", this.statusPageController.resolveStatusPageByDomain);
|
||||
this.router.get("/:url", verifyStatusPageAccess, this.statusPageController.getStatusPageByUrl);
|
||||
this.router.delete("/:id", verifyJWT, this.statusPageController.deleteStatusPage);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { booleanCoercion } from "./shared.js";
|
||||
import { booleanCoercion, dnsHostnameRegex } from "./shared.js";
|
||||
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js";
|
||||
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
|
||||
|
||||
//****************************************
|
||||
// Status Page Validations
|
||||
@@ -15,6 +16,20 @@ export const getStatusPageQueryValidation = z.object({
|
||||
timeFrame: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export const resolveStatusPageQueryValidation = getStatusPageQueryValidation.extend({
|
||||
domain: z.string().optional(),
|
||||
});
|
||||
|
||||
const customDomainValidation = z.preprocess(
|
||||
(val) => {
|
||||
if (val === undefined || val === null) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeStatusPageDomain(String(val));
|
||||
},
|
||||
z.union([z.string().regex(dnsHostnameRegex, "Enter a valid domain name (e.g. status.example.com)"), z.null()]).optional()
|
||||
);
|
||||
|
||||
export const createStatusPageBodyValidation = z
|
||||
.object({
|
||||
type: z.union([z.enum(StatusPageTypes), z.array(z.enum(StatusPageTypes))]).transform((val) => (Array.isArray(val) ? val : [val])),
|
||||
@@ -22,6 +37,7 @@ export const createStatusPageBodyValidation = z
|
||||
url: z.string().regex(/^[a-zA-Z0-9_-]+$/, {
|
||||
message: "URL can only contain letters, numbers, underscores, and hyphens",
|
||||
}),
|
||||
customDomain: customDomainValidation,
|
||||
timezone: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
monitors: z.array(z.string().regex(/^[0-9a-fA-F]{24}$/, "Must be a valid monitor ID")).min(1, "At least one monitor is required"),
|
||||
|
||||
+17
-9
@@ -1,6 +1,6 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import cors from "cors";
|
||||
import cors, { type CorsOptions } from "cors";
|
||||
import helmet from "helmet";
|
||||
import compression from "compression";
|
||||
import cookieParser from "cookie-parser";
|
||||
@@ -12,6 +12,8 @@ import { setupRoutes } from "@/config/routes.js";
|
||||
import { InitializedServices } from "@/config/services.js";
|
||||
import { InitializedControllers } from "@/config/controllers.js";
|
||||
import { EnvConfig } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { createStatusPageCorsOrigin } from "@/api/middleware/statusPageCorsOrigin.js";
|
||||
import { isPublicStatusPageApiPath } from "@/api/middleware/statusPagePublicApiPath.js";
|
||||
|
||||
export const createApp = ({
|
||||
services,
|
||||
@@ -28,17 +30,23 @@ export const createApp = ({
|
||||
}) => {
|
||||
const allowedOrigin = envSettings.clientHost;
|
||||
const app = express();
|
||||
const defaultCorsOptions: CorsOptions = {
|
||||
origin: allowedOrigin,
|
||||
methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"],
|
||||
credentials: true,
|
||||
};
|
||||
const publicStatusPageCorsOrigin = createStatusPageCorsOrigin(allowedOrigin, services.statusPagesRepository);
|
||||
|
||||
app.use(generalApiLimiter);
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: allowedOrigin,
|
||||
methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
|
||||
allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"],
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
app.use((req, res, next) => {
|
||||
const corsOptions = isPublicStatusPageApiPath(req.method, req.path)
|
||||
? { ...defaultCorsOptions, origin: publicStatusPageCorsOrigin }
|
||||
: defaultCorsOptions;
|
||||
|
||||
return cors(corsOptions)(req, res, next);
|
||||
});
|
||||
|
||||
app.use(express.static(frontendPath));
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface IStatusPagesRepository {
|
||||
create(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
|
||||
// single fetch
|
||||
findByUrl(url: string): Promise<StatusPage>;
|
||||
findByCustomDomain(customDomain: string): Promise<StatusPage>;
|
||||
findByTeamId(teamId: string): Promise<StatusPage[]>;
|
||||
// collection fetch
|
||||
// update
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type StatusPageDocument, StatusPageModel } from "@/domain/status-pages/
|
||||
import type { StatusPage, StatusPageLogo, StatusPageLogoDocument } from "@/domain/status-pages/status-page.type.js";
|
||||
import mongoose from "mongoose";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
|
||||
|
||||
// Type for update data that can include document-level fields (Buffer for logo)
|
||||
type StatusPageUpdateData = Partial<Omit<StatusPage, "id" | "userId" | "teamId" | "logo" | "createdAt" | "updatedAt">> & {
|
||||
@@ -47,6 +48,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
|
||||
type: doc.type,
|
||||
companyName: doc.companyName,
|
||||
url: doc.url,
|
||||
customDomain: doc.customDomain ?? null,
|
||||
timezone: doc.timezone ?? undefined,
|
||||
color: doc.color,
|
||||
monitors: this.mapIdArray(doc.monitors),
|
||||
@@ -74,10 +76,12 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
|
||||
};
|
||||
|
||||
create = async (userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
|
||||
const { logo, ...restData } = data;
|
||||
const { logo, customDomain, ...restData } = data;
|
||||
void logo;
|
||||
const normalizedCustomDomain = normalizeStatusPageDomain(customDomain);
|
||||
const statusPage = new StatusPageModel({
|
||||
...restData,
|
||||
...(normalizedCustomDomain ? { customDomain: normalizedCustomDomain } : {}),
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
@@ -102,6 +106,21 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
|
||||
// Get status page
|
||||
};
|
||||
|
||||
findByCustomDomain = async (customDomain: string): Promise<StatusPage> => {
|
||||
const normalizedDomain = normalizeStatusPageDomain(customDomain);
|
||||
if (!normalizedDomain) {
|
||||
throw new AppError({ message: "Status page not found", status: 404 });
|
||||
}
|
||||
|
||||
const statusPage = await StatusPageModel.findOne({
|
||||
customDomain: normalizedDomain,
|
||||
});
|
||||
if (!statusPage) {
|
||||
throw new AppError({ message: "Status page not found", status: 404 });
|
||||
}
|
||||
return this.toEntity(statusPage);
|
||||
};
|
||||
|
||||
findByTeamId = async (teamId: string): Promise<StatusPage[]> => {
|
||||
const statusPages = await StatusPageModel.find({ teamId });
|
||||
return this.mapDocuments(statusPages);
|
||||
@@ -113,19 +132,37 @@ class MongoStatusPagesRepository implements IStatusPagesRepository {
|
||||
image: Express.Multer.File | undefined,
|
||||
patch: Partial<StatusPage> & { removeLogo?: string }
|
||||
): Promise<StatusPage> => {
|
||||
const { logo, removeLogo, ...restPatch } = patch;
|
||||
const { logo, removeLogo, customDomain, ...restPatch } = patch;
|
||||
void logo;
|
||||
const updateData: StatusPageUpdateData = { ...restPatch };
|
||||
const setData: StatusPageUpdateData = { ...restPatch };
|
||||
const unsetData: Record<string, 1> = {};
|
||||
|
||||
if (customDomain !== undefined) {
|
||||
const normalizedCustomDomain = normalizeStatusPageDomain(customDomain);
|
||||
if (normalizedCustomDomain) {
|
||||
setData.customDomain = normalizedCustomDomain;
|
||||
} else {
|
||||
unsetData.customDomain = 1;
|
||||
}
|
||||
}
|
||||
if (image) {
|
||||
updateData.logo = {
|
||||
setData.logo = {
|
||||
data: image.buffer as Buffer,
|
||||
contentType: image.mimetype,
|
||||
};
|
||||
} else if (removeLogo === "true") {
|
||||
updateData.logo = null;
|
||||
setData.logo = null;
|
||||
}
|
||||
|
||||
const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateData, {
|
||||
const updateQuery =
|
||||
unsetData.customDomain !== undefined
|
||||
? {
|
||||
$set: setData,
|
||||
$unset: unsetData,
|
||||
}
|
||||
: setData;
|
||||
|
||||
const statusPage = await StatusPageModel.findOneAndUpdate({ teamId, _id: id }, updateQuery, {
|
||||
new: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -65,6 +65,11 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
|
||||
required: true,
|
||||
default: "",
|
||||
},
|
||||
customDomain: {
|
||||
type: String,
|
||||
lowercase: true,
|
||||
trim: true,
|
||||
},
|
||||
timezone: {
|
||||
type: String,
|
||||
},
|
||||
@@ -128,6 +133,14 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
|
||||
{ timestamps: true }
|
||||
);
|
||||
|
||||
StatusPageSchema.index(
|
||||
{ customDomain: 1 },
|
||||
{
|
||||
unique: true,
|
||||
partialFilterExpression: { customDomain: { $type: "string" } },
|
||||
}
|
||||
);
|
||||
|
||||
const StatusPageModel = model<StatusPageDocument>("StatusPage", StatusPageSchema);
|
||||
|
||||
export type { StatusPageDocument };
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { type IStatusPagesRepository } from "@/domain/status-pages/status-page-repository.interface.js";
|
||||
import { ISettingsService } from "@/domain/app-settings/app-settings.service.js";
|
||||
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/domain/status-pages/status-page.type.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
|
||||
|
||||
export interface IStatusPageService {
|
||||
createStatusPage(userId: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
|
||||
getStatusPageByUrl(url: string): Promise<StatusPage>;
|
||||
getStatusPageByCustomDomain(customDomain: string): Promise<StatusPage>;
|
||||
getStatusPagesByTeamId(teamId: string): Promise<StatusPage[]>;
|
||||
updateStatusPage(id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage>;
|
||||
|
||||
@@ -19,6 +22,30 @@ export class StatusPageService implements IStatusPageService {
|
||||
this.settingsService = settingsService;
|
||||
}
|
||||
|
||||
private assertCustomDomainAllowed = (customDomain: string | null | undefined) => {
|
||||
if (!customDomain) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clientHost = normalizeStatusPageDomain(this.settingsService.getSettings().clientHost);
|
||||
if (clientHost && customDomain === clientHost) {
|
||||
throw new AppError({
|
||||
message: "Custom domain cannot match the Checkmate instance host",
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private normalizeCustomDomainInput = (data: Partial<StatusPage>): Partial<StatusPage> => {
|
||||
if (!("customDomain" in data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const customDomain = normalizeStatusPageDomain(data.customDomain);
|
||||
this.assertCustomDomainAllowed(customDomain);
|
||||
return { ...data, customDomain };
|
||||
};
|
||||
|
||||
private withoutThemeFields = (data: Partial<StatusPage>): Partial<StatusPage> => {
|
||||
const { theme: _theme, themeMode: _themeMode, ...rest } = data;
|
||||
return rest;
|
||||
@@ -42,7 +69,8 @@ export class StatusPageService implements IStatusPageService {
|
||||
image: Express.Multer.File | undefined,
|
||||
data: Partial<StatusPage>
|
||||
): Promise<StatusPage> => {
|
||||
const created = await this.statusPagesRepository.create(userId, teamId, image, this.normalizeInput(data));
|
||||
const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data));
|
||||
const created = await this.statusPagesRepository.create(userId, teamId, image, normalizedData);
|
||||
return this.normalizeTheme(created);
|
||||
};
|
||||
|
||||
@@ -51,13 +79,19 @@ export class StatusPageService implements IStatusPageService {
|
||||
return this.normalizeTheme(statusPage);
|
||||
};
|
||||
|
||||
getStatusPageByCustomDomain = async (customDomain: string): Promise<StatusPage> => {
|
||||
const statusPage = await this.statusPagesRepository.findByCustomDomain(customDomain);
|
||||
return this.normalizeTheme(statusPage);
|
||||
};
|
||||
|
||||
getStatusPagesByTeamId = async (teamId: string): Promise<StatusPage[]> => {
|
||||
const statusPages = await this.statusPagesRepository.findByTeamId(teamId);
|
||||
return statusPages.map((sp) => this.normalizeTheme(sp));
|
||||
};
|
||||
|
||||
updateStatusPage = async (id: string, teamId: string, image: Express.Multer.File | undefined, data: Partial<StatusPage>): Promise<StatusPage> => {
|
||||
const updated = await this.statusPagesRepository.updateById(id, teamId, image, this.normalizeInput(data));
|
||||
const normalizedData = this.normalizeCustomDomainInput(this.normalizeInput(data));
|
||||
const updated = await this.statusPagesRepository.updateById(id, teamId, image, normalizedData);
|
||||
return this.normalizeTheme(updated);
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface StatusPage {
|
||||
type: StatusPageType[];
|
||||
companyName: string;
|
||||
url: string;
|
||||
customDomain?: string | null;
|
||||
timezone?: string;
|
||||
color: string;
|
||||
monitors: string[];
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const normalizeStatusPageDomain = (value?: string | null): string | null => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = value.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Strip scheme, path, and port if a full URL was pasted.
|
||||
const withoutScheme = trimmed.replace(/^https?:\/\//, "");
|
||||
const hostname = withoutScheme.split("/")[0]?.split(":")[0] ?? "";
|
||||
|
||||
return hostname || null;
|
||||
};
|
||||
|
||||
export const resolveStatusPageDomainFromRequest = (hostHeader?: string | null, queryDomain?: string | null): string | null => {
|
||||
const fromQuery = normalizeStatusPageDomain(queryDomain);
|
||||
if (fromQuery) {
|
||||
return fromQuery;
|
||||
}
|
||||
|
||||
if (!hostHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeStatusPageDomain(hostHeader.split(":")[0]);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, jest, beforeEach } from "@jest/globals";
|
||||
import { createStatusPageCorsOrigin } from "../../../src/api/middleware/statusPageCorsOrigin.ts";
|
||||
import type { IStatusPagesRepository } from "../../../src/domain/status-pages/status-page-repository.interface.ts";
|
||||
import type { StatusPage } from "../../../src/domain/status-pages/status-page.type.ts";
|
||||
|
||||
const makeStatusPage = (overrides?: Partial<StatusPage>): StatusPage =>
|
||||
({
|
||||
id: "sp-1",
|
||||
isPublished: true,
|
||||
customDomain: "status.example.com",
|
||||
...overrides,
|
||||
}) as StatusPage;
|
||||
|
||||
const createRepo = () =>
|
||||
({
|
||||
findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()),
|
||||
}) as unknown as jest.Mocked<Pick<IStatusPagesRepository, "findByCustomDomain">>;
|
||||
|
||||
const invokeOrigin = (originFn: ReturnType<typeof createStatusPageCorsOrigin>, origin: string): Promise<boolean | string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
originFn?.(origin, (error, result) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(result ?? false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStatusPageCorsOrigin", () => {
|
||||
let repo: jest.Mocked<Pick<IStatusPagesRepository, "findByCustomDomain">>;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = createRepo();
|
||||
});
|
||||
|
||||
it("allows the configured client host without a repository lookup", async () => {
|
||||
const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository);
|
||||
|
||||
await expect(invokeOrigin(originFn, "https://checkmate.example.com")).resolves.toBe(true);
|
||||
expect(repo.findByCustomDomain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches published custom domain lookups", async () => {
|
||||
const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository);
|
||||
|
||||
await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe("https://status.example.com");
|
||||
await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe("https://status.example.com");
|
||||
|
||||
expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1);
|
||||
expect(repo.findByCustomDomain).toHaveBeenCalledWith("status.example.com");
|
||||
});
|
||||
|
||||
it("rejects unpublished custom domains", async () => {
|
||||
repo.findByCustomDomain.mockResolvedValue(makeStatusPage({ isPublished: false }));
|
||||
const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository);
|
||||
|
||||
await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe(false);
|
||||
await expect(invokeOrigin(originFn, "https://status.example.com")).resolves.toBe(false);
|
||||
|
||||
expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("caches unknown custom domain lookups", async () => {
|
||||
repo.findByCustomDomain.mockRejectedValue(new Error("Status page not found"));
|
||||
const originFn = createStatusPageCorsOrigin("https://checkmate.example.com", repo as IStatusPagesRepository);
|
||||
|
||||
await expect(invokeOrigin(originFn, "https://unknown.example.com")).resolves.toBe(false);
|
||||
await expect(invokeOrigin(originFn, "https://unknown.example.com")).resolves.toBe(false);
|
||||
|
||||
expect(repo.findByCustomDomain).toHaveBeenCalledTimes(1);
|
||||
expect(repo.findByCustomDomain).toHaveBeenCalledWith("unknown.example.com");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { isPublicStatusPageApiPath } from "../../../src/api/middleware/statusPagePublicApiPath.ts";
|
||||
|
||||
describe("isPublicStatusPageApiPath", () => {
|
||||
it("allows public resolve and slug routes", () => {
|
||||
expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/resolve")).toBe(true);
|
||||
expect(isPublicStatusPageApiPath("OPTIONS", "/api/v1/status-page/resolve")).toBe(true);
|
||||
expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/my-status-page")).toBe(true);
|
||||
expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/my-status-page?type=uptime")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects authenticated and mutating status page routes", () => {
|
||||
expect(isPublicStatusPageApiPath("GET", "/api/v1/status-page/team")).toBe(false);
|
||||
expect(isPublicStatusPageApiPath("POST", "/api/v1/status-page")).toBe(false);
|
||||
expect(isPublicStatusPageApiPath("PUT", "/api/v1/status-page/64b1f2a3c4d5e6f7a8b9c0d1")).toBe(false);
|
||||
expect(isPublicStatusPageApiPath("DELETE", "/api/v1/status-page/64b1f2a3c4d5e6f7a8b9c0d1")).toBe(false);
|
||||
expect(isPublicStatusPageApiPath("GET", "/api/v1/monitors")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,9 @@ import { AppError } from "../../../src/utils/AppError.ts";
|
||||
|
||||
// Reaches into the multer instance config to exercise the fileFilter/limits in isolation.
|
||||
// Guards against regressing GHSA-9xvg-x28f-m78m (pre-auth DoS via unbounded uploads).
|
||||
const config = (imageUpload as unknown as { fileFilter: Function; limits: { fileSize: number; files: number } });
|
||||
const config = imageUpload as unknown as { fileFilter: Function; limits: { fileSize: number; files: number } };
|
||||
|
||||
const makeFile = (mimetype: string): Express.Multer.File =>
|
||||
({ fieldname: "profileImage", originalname: "x", mimetype }) as Express.Multer.File;
|
||||
const makeFile = (mimetype: string): Express.Multer.File => ({ fieldname: "profileImage", originalname: "x", mimetype }) as Express.Multer.File;
|
||||
|
||||
const runFilter = (mimetype: string): Promise<{ error: unknown; accepted: unknown }> =>
|
||||
new Promise((resolve) => {
|
||||
|
||||
@@ -26,20 +26,22 @@ const createRepo = () =>
|
||||
({
|
||||
create: jest.fn().mockResolvedValue(makeStatusPage()),
|
||||
findByUrl: jest.fn().mockResolvedValue(makeStatusPage()),
|
||||
findByCustomDomain: jest.fn().mockResolvedValue(makeStatusPage()),
|
||||
findByTeamId: jest.fn().mockResolvedValue([makeStatusPage()]),
|
||||
updateById: jest.fn().mockResolvedValue(makeStatusPage()),
|
||||
deleteById: jest.fn().mockResolvedValue(makeStatusPage()),
|
||||
removeMonitorFromStatusPages: jest.fn().mockResolvedValue(1),
|
||||
}) as unknown as jest.Mocked<IStatusPagesRepository>;
|
||||
|
||||
const createSettingsService = (themesEnabled: boolean) =>
|
||||
const createSettingsService = (themesEnabled: boolean, clientHost = "http://localhost:5173") =>
|
||||
({
|
||||
areStatusPageThemesEnabled: jest.fn().mockReturnValue(themesEnabled),
|
||||
getSettings: jest.fn().mockReturnValue({ clientHost }),
|
||||
}) as unknown as jest.Mocked<ISettingsService>;
|
||||
|
||||
const createService = (themesEnabled = true) => {
|
||||
const createService = (themesEnabled = true, clientHost = "http://localhost:5173") => {
|
||||
const repo = createRepo();
|
||||
const settingsService = createSettingsService(themesEnabled);
|
||||
const settingsService = createSettingsService(themesEnabled, clientHost);
|
||||
const service = new StatusPageService(repo, settingsService);
|
||||
return { service, repo, settingsService };
|
||||
};
|
||||
@@ -99,6 +101,50 @@ describe("StatusPageService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStatusPageByCustomDomain", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, repo } = createService(true);
|
||||
|
||||
const result = await service.getStatusPageByCustomDomain("status.example.com");
|
||||
|
||||
expect(repo.findByCustomDomain).toHaveBeenCalledWith("status.example.com");
|
||||
expect(result).toEqual(makeStatusPage());
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStatusPage custom domain validation", () => {
|
||||
it("rejects custom domains that match the Checkmate instance host", async () => {
|
||||
const { service } = createService(true, "https://checkmate.example.com");
|
||||
|
||||
await expect(
|
||||
service.createStatusPage("user-1", "team-1", undefined, {
|
||||
customDomain: "checkmate.example.com",
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes custom domains before create", async () => {
|
||||
const { service, repo } = createService(true);
|
||||
|
||||
await service.createStatusPage("user-1", "team-1", undefined, {
|
||||
customDomain: "https://Status.Example.COM",
|
||||
});
|
||||
|
||||
expect(repo.create).toHaveBeenCalledWith("user-1", "team-1", undefined, expect.objectContaining({ customDomain: "status.example.com" }));
|
||||
});
|
||||
|
||||
it("allows multiple status pages without a custom domain", async () => {
|
||||
const { service, repo } = createService(true);
|
||||
|
||||
await service.createStatusPage("user-1", "team-1", undefined, { companyName: "First" });
|
||||
await service.createStatusPage("user-1", "team-1", undefined, { companyName: "Second" });
|
||||
|
||||
expect(repo.create).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStatusPagesByTeamId", () => {
|
||||
it("delegates to repository", async () => {
|
||||
const { service, repo } = createService(true);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import { normalizeStatusPageDomain, resolveStatusPageDomainFromRequest } from "../../../src/utils/statusPageDomain.ts";
|
||||
|
||||
describe("statusPageDomain utils", () => {
|
||||
describe("normalizeStatusPageDomain", () => {
|
||||
it("returns null for empty values", () => {
|
||||
expect(normalizeStatusPageDomain("")).toBeNull();
|
||||
expect(normalizeStatusPageDomain(" ")).toBeNull();
|
||||
expect(normalizeStatusPageDomain(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes hostnames", () => {
|
||||
expect(normalizeStatusPageDomain("Status.Example.COM")).toBe("status.example.com");
|
||||
expect(normalizeStatusPageDomain("https://status.example.com/path")).toBe("status.example.com");
|
||||
expect(normalizeStatusPageDomain("status.example.com:443")).toBe("status.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveStatusPageDomainFromRequest", () => {
|
||||
it("prefers the query domain over the host header", () => {
|
||||
expect(resolveStatusPageDomainFromRequest("checkmate.example.com", "status.example.com")).toBe("status.example.com");
|
||||
});
|
||||
|
||||
it("falls back to the host header", () => {
|
||||
expect(resolveStatusPageDomainFromRequest("status.example.com:8443", undefined)).toBe("status.example.com");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user