Add custom domain support for published status pages.

Fixes #3647
This commit is contained in:
Ian Troisi committed 2026-06-07 22:37:24 -04:00
1 parent 80d6868a1d
commit 30e8cfc9ec
25 files changed
+13919 -11044

No files matched your search

+1
View File
@@ -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"
);
+13 -3
View File
@@ -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
+16
View File
@@ -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
+1
View File
@@ -47,6 +47,7 @@ export interface StatusPage {
type: MonitorDisplayType[];
companyName: string;
url: string;
customDomain?: string | null;
timezone?: string;
color: string;
monitors: string[];
+72
View File
@@ -0,0 +1,72 @@
import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage";
const CLIENT_HOST = import.meta.env.VITE_APP_CLIENT_HOST as string | undefined;
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 `https://${statusPage.customDomain}`;
}
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};
export const getStatusPagePublicUrl = (
statusPage: Pick<StatusPage, "url" | "customDomain">
): string => {
if (statusPage.customDomain) {
return `https://${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;
};
+23
View File
@@ -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"]))
+5
View File
@@ -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."
}
}
},
+13444 -10993
View File
File diff suppressed because it is too large. Load diff
+15 -1
View File
@@ -1,6 +1,11 @@
import { registry } from "../registry.js";
import { bearer, multipart, okJsonNoData, okUnknown, standardErrors } from "../helpers.js";
import { createStatusPageBodyValidation, getStatusPageParamValidation, getStatusPageQueryValidation } from "@/validation/statusPageValidation.js";
import {
createStatusPageBodyValidation,
getStatusPageParamValidation,
getStatusPageQueryValidation,
resolveStatusPageQueryValidation,
} from "@/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}",
+2 -1
View File
@@ -12,6 +12,7 @@ import { setupRoutes } from "@/config/routes.js";
import { InitializedServices } from "@/config/services.js";
import { InitializedControllers } from "@/config/controllers.js";
import { EnvConfig } from "@/service/system/settingsService.js";
import { createStatusPageCorsOrigin } from "@/middleware/statusPageCorsOrigin.js";
export const createApp = ({
services,
@@ -33,7 +34,7 @@ export const createApp = ({
app.use(
cors({
origin: allowedOrigin,
origin: createStatusPageCorsOrigin(allowedOrigin, services.statusPagesRepository),
methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
allowedHeaders: ["Content-Type", "Authorization", "Accept-Language"],
credentials: true,
+68 -32
View File
@@ -5,6 +5,7 @@ import {
getStatusPageParamValidation,
getStatusPageQueryValidation,
imageValidation,
resolveStatusPageQueryValidation,
} from "@/validation/statusPageValidation.js";
import { AppError } from "@/utils/AppError.js";
import { requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
@@ -12,6 +13,8 @@ import { IStatusPageService } from "@/service/business/statusPageService.js";
import { IMonitorsRepository } from "@/repositories/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { NormalizeData } from "@/utils/dataUtils.js";
import { resolveStatusPageDomainFromRequest } from "@/utils/statusPageDomain.js";
import type { StatusPage } from "@/types/statusPage.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);
+8
View File
@@ -65,6 +65,14 @@ const StatusPageSchema = new Schema<StatusPageDocument>(
required: true,
default: "",
},
customDomain: {
type: String,
default: null,
lowercase: true,
trim: true,
sparse: true,
unique: true,
},
timezone: {
type: String,
},
@@ -0,0 +1,39 @@
import type { CorsOptions } from "cors";
import type { IStatusPagesRepository } from "@/repositories/index.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
export const createStatusPageCorsOrigin = (clientHost: string, statusPagesRepository: IStatusPagesRepository): CorsOptions["origin"] => {
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;
}
statusPagesRepository
.findByCustomDomain(customDomain)
.then((statusPage) => {
callback(null, statusPage.isPublished ? origin : false);
})
.catch(() => {
callback(null, false);
});
};
};
@@ -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 "@/db/models/StatusPage
import type { StatusPage, StatusPageLogo, StatusPageLogoDocument } from "@/types/statusPage.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,11 @@ 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 statusPage = new StatusPageModel({
...restData,
customDomain: normalizeStatusPageDomain(customDomain),
userId,
teamId,
});
@@ -102,6 +105,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,9 +131,12 @@ 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 };
if (customDomain !== undefined) {
updateData.customDomain = normalizeStatusPageDomain(customDomain);
}
if (image) {
updateData.logo = {
data: image.buffer as Buffer,
+1
View File
@@ -19,6 +19,7 @@ class StatusPageRoutes {
this.router.post("/", upload.single("logo"), verifyJWT, this.statusPageController.createStatusPage);
this.router.put("/:id", upload.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,10 +1,13 @@
import { type IStatusPagesRepository } from "@/repositories/index.js";
import { ISettingsService } from "@/service/system/settingsService.js";
import { DEFAULT_STATUS_PAGE_THEME, DEFAULT_STATUS_PAGE_THEME_MODE, StatusPage } from "@/types/index.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);
};
+1
View File
@@ -26,6 +26,7 @@ export interface StatusPage {
type: StatusPageType[];
companyName: string;
url: string;
customDomain?: string | null;
timezone?: string;
color: string;
monitors: string[];
+29
View File
@@ -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]);
};
+17 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { booleanCoercion } from "./shared.js";
import { booleanCoercion, dnsHostnameRegex } from "./shared.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/types/statusPage.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"),
@@ -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,41 @@ 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" }));
});
});
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");
});
});
});