1 parent
80d6868a1d
commit
30e8cfc9ec
25 files changed
+13919
-11044
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,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;
|
||||
};
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+13444
-10993
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user