Merge pull request #3715 from Cincinnatus101010/feat/status-page-custom-domain

feat: custom domain support for published status pages
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-06-11 12:31:20 -07:00
commit 1aa7ae8f11
31 files changed
+815 -69

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[];
+89
View File
@@ -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;
};
+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."
}
}
},
+118
View File
@@ -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"],
+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 "@/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);
+1 -2
View File
@@ -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;
};
+7 -1
View File
@@ -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);
}
+1
View File
@@ -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
View File
@@ -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[];
+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]);
};
@@ -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);
});
});
+2 -3
View File
@@ -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");
});
});
});