Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"**/*": "prettier --write --ignore-unknown"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hello-pangea/dnd": "^18.0.0",
Expand Down
2 changes: 1 addition & 1 deletion client/src/Components/inputs/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
width: "100%",
"& .MuiOutlinedInput-root": {
borderRadius: theme.shape.borderRadius,
height: 34,
height: props.multiline ? "auto" : 34,
fontSize: typographyLevels.base,
overflow: "hidden",
},
Expand Down
28 changes: 28 additions & 0 deletions client/src/Pages/StatusPage/Create/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ const CreateStatusPage = () => {
fd.append("showUptimePercentage", String(data.showUptimePercentage));
fd.append("showAdminLoginLink", String(data.showAdminLoginLink));
fd.append("showInfrastructure", String(data.showInfrastructure));
fd.append("customCSS", data.customCSS ?? "");
if (data.theme) fd.append("theme", data.theme);
if (data.themeMode) fd.append("themeMode", data.themeMode);

Expand Down Expand Up @@ -530,6 +531,33 @@ const CreateStatusPage = () => {
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.customCSS.title")}
subtitle={t("pages.statusPages.form.customCSS.description")}
rightContent={
<Controller
name="customCSS"
control={control}
render={({ field, fieldState }) => (
<TextField
{...field}
multiline
rows={8}
fieldLabel={t(
"pages.statusPages.form.customCSS.option.customCSS.label"
)}
placeholder={t(
"pages.statusPages.form.customCSS.option.customCSS.placeholder"
)}
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
}
/>
)}
{showStep(1) && (
<ConfigBox
title={t("pages.statusPages.form.features.title")}
Expand Down
30 changes: 19 additions & 11 deletions client/src/Pages/StatusPage/Status/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { BasePage, BaseFallback } from "@/Components/design-elements";
import Typography from "@mui/material/Typography";
import { Link } from "react-router-dom";
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";

import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
Expand All @@ -16,6 +17,7 @@ import {
} from "@/Types/StatusPage";
import {
buildStatusPageApiPath,
getStatusPagePreviewUrl,
getStatusPagePublicUrl,
isCustomDomainHost,
} from "@/Utils/statusPageUrl";
Expand Down Expand Up @@ -121,30 +123,27 @@ const StatusPageView = () => {
);
}

const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
const themedRenderer = (
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
);

// Public route: render directly on the viewport, themed background covers everything.
if (isPublic) {
const themeConfig = THEME_CONFIGS[resolveStatusPageTheme(statusPage.theme)];
return (
<StatusPageThemeProvider
theme={statusPage.theme}
themeMode={statusPage.themeMode}
timezone={statusPage.timezone}
paintBody
>
{themedRenderer}
<BaseStatusPage
statusPage={statusPage}
monitors={monitors}
config={themeConfig}
/>
</StatusPageThemeProvider>
);
}

const publicUrl = getStatusPagePublicUrl(statusPage);
const previewUrl = getStatusPagePreviewUrl(statusPage);
return (
<BasePage
loading={isLoading}
Expand All @@ -163,7 +162,16 @@ const StatusPageView = () => {
timezone={statusPage.timezone}
transparent
>
<BrowserFrame url={publicUrl}>{themedRenderer}</BrowserFrame>
<BrowserFrame url={publicUrl}>
<Box
component="iframe"
src={previewUrl}
title={t("pages.statusPages.preview.title")}
flex={1}
width="100%"
border={0}
/>
</BrowserFrame>
</StatusPageThemeProvider>
</BasePage>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,13 @@ export const BrowserFrame = ({ url, children }: Props) => {
{url}
</Box>
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>{children}</Box>
<Box
display="flex"
flexDirection="column"
sx={{ flex: 1, minHeight: 0 }}
>
{children}
</Box>
</Box>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {

return (
<Box sx={styles.page}>
{statusPage.customCSS && <style>{statusPage.customCSS}</style>}
<Stack
component="header"
direction={{ xs: "column", md: "row" }}
Expand Down
16 changes: 16 additions & 0 deletions client/src/Utils/customCss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { isTokenString, isTokenURL, tokenize } from "@csstools/css-tokenizer";

// Mirrors the server check (server/src/utils/customCss.ts) so the form rejects
// the same CSS the API would.
const EXTERNAL_TARGET = /^\s*(?:https?:|\/\/)/i;

export const cssReferencesExternalResource = (css?: string | null): boolean => {
if (typeof css !== "string" || css === "") {
return false;
}

return tokenize({ css }).some(
(token) =>
(isTokenURL(token) || isTokenString(token)) && EXTERNAL_TARGET.test(token[4].value)
);
};
10 changes: 10 additions & 0 deletions client/src/Utils/statusPageUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ export const getStatusPagePublicUrl = (
return `${PUBLIC_STATUS_PAGE_PREFIX}/${statusPage.url}`;
};

// Same-origin URL for the admin preview iframe; a custom domain would be cross
// origin and blocked by the admin document's CSP frame-src.
export const getStatusPagePreviewUrl = (statusPage: Pick<StatusPage, "url">): string => {
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;
Expand Down
10 changes: 9 additions & 1 deletion client/src/Validation/statusPage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";
import type { FieldPath } from "react-hook-form";
import { STATUS_PAGE_THEMES, STATUS_PAGE_THEME_MODES } from "@/Types/StatusPage";
import { cssReferencesExternalResource } from "@/Utils/customCss";

// Wizard step a field is validated on. Attached inline to each field below so
// the grouping lives next to the field definition; unannotated fields default
Expand Down Expand Up @@ -58,7 +59,14 @@ export const statusPageSchema = z.object({
showUptimePercentage: z.boolean().register(statusPageStepRegistry, { step: 1 }),
showAdminLoginLink: z.boolean().register(statusPageStepRegistry, { step: 1 }),
showInfrastructure: z.boolean().register(statusPageStepRegistry, { step: 1 }),
customCSS: z.string().optional().register(statusPageStepRegistry, { step: 1 }),
customCSS: z
.string()
.max(100000, "Custom CSS must be at most 100000 characters")
.refine((css) => !cssReferencesExternalResource(css), {
message: "Custom CSS cannot reference external URLs or use @import",
})
.optional()
.register(statusPageStepRegistry, { step: 1 }),
Comment thread
Buco7854 marked this conversation as resolved.
theme: z
.enum(STATUS_PAGE_THEMES)
.optional()
Expand Down
13 changes: 13 additions & 0 deletions client/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1594,6 +1594,16 @@
"light": "Light only",
"dark": "Dark only"
},
"customCSS": {
"title": "Custom CSS",
"description": "Add custom CSS to fine-tune the look of your public status page.",
"option": {
"customCSS": {
"label": "Custom CSS",
"placeholder": "footer { display: none; }"
}
}
},
"features": {
"title": "Features",
"description": "Configure what information is displayed on your status page.",
Expand All @@ -1616,6 +1626,9 @@
"header": {
"title": "Status pages",
"description": "Publish public pages that show real-time uptime and incident history to your customers and stakeholders."
},
"preview": {
"title": "Status page preview"
}
},
"uptime": {
Expand Down
8 changes: 8 additions & 0 deletions server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10520,6 +10520,10 @@
"showInfrastructure": {
"type": "boolean"
},
"customCSS": {
"type": "string",
"maxLength": 100000
},
"removeLogo": {
"anyOf": [
{
Expand Down Expand Up @@ -10734,6 +10738,10 @@
"showInfrastructure": {
"type": "boolean"
},
"customCSS": {
"type": "string",
"maxLength": 100000
},
"removeLogo": {
"anyOf": [
{
Expand Down
1 change: 1 addition & 0 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"node": ">=20"
},
"dependencies": {
"@csstools/css-tokenizer": "^3.0.4",
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"axios": "^1.15.2",
Expand Down
27 changes: 27 additions & 0 deletions server/src/api/middleware/statusPageDocumentCsp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { NextFunction, Request, Response } from "express";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";

const PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX = "/status/public";
Comment thread
Buco7854 marked this conversation as resolved.

// Browsers enforce the intersection of all CSP headers, so this only tightens
// the status page on top of the global helmet policy, blocking external images,
// fonts, and stylesheets from custom CSS while keeping the app's Google Fonts.
const STATUS_PAGE_CSP = [
"img-src 'self' data:",
"font-src 'self' data: https://fonts.gstatic.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
].join("; ");

// A status page document is served on the public path or on a custom domain
// (any host other than the app's own). Mirrors the client isCustomDomainHost.
export const createStatusPageDocumentCsp = (clientHost: string) => {
const appHostname = normalizeStatusPageDomain(clientHost);

return (req: Request, res: Response, next: NextFunction) => {
const onCustomDomain = appHostname !== null && normalizeStatusPageDomain(req.hostname) !== appHostname;
if (req.path.startsWith(PUBLIC_STATUS_PAGE_DOCUMENT_PREFIX) || onCustomDomain) {
res.append("Content-Security-Policy", STATUS_PAGE_CSP);
}
return next();
};
};
7 changes: 4 additions & 3 deletions server/src/api/routes/statusPageRoute.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IStatusPageController } from "@/api/controllers/statusPageController.js";
import { RequestHandler, Router } from "express";
import { isAllowed } from "@/api/middleware/isAllowed.js";
import { imageUpload } from "@/api/middleware/upload.js";

class StatusPageRoutes {
Expand All @@ -15,12 +16,12 @@ class StatusPageRoutes {
initRoutes(verifyJWT: RequestHandler, verifyStatusPageAccess: RequestHandler) {
this.router.get("/team", verifyJWT, this.statusPageController.getStatusPagesByTeamId);

this.router.post("/", imageUpload.single("logo"), verifyJWT, this.statusPageController.createStatusPage);
this.router.put("/:id", imageUpload.single("logo"), verifyJWT, this.statusPageController.updateStatusPage);
this.router.post("/", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.createStatusPage);
this.router.put("/:id", imageUpload.single("logo"), verifyJWT, isAllowed(["admin", "superadmin"]), 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);
this.router.delete("/:id", verifyJWT, isAllowed(["admin", "superadmin"]), this.statusPageController.deleteStatusPage);
}

getRouter() {
Expand Down
8 changes: 8 additions & 0 deletions server/src/api/validation/statusPageValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { z } from "zod";
import { booleanCoercion, dnsHostnameRegex } from "./shared.js";
import { StatusPageTypes, StatusPageThemes, StatusPageThemeModes } from "@/domain/status-pages/status-page.type.js";
import { normalizeStatusPageDomain } from "@/utils/statusPageDomain.js";
import { cssReferencesExternalResource } from "@/utils/customCss.js";

//****************************************
// Status Page Validations
Expand Down Expand Up @@ -48,6 +49,13 @@ export const createStatusPageBodyValidation = z
showUptimePercentage: booleanCoercion,
showAdminLoginLink: booleanCoercion.optional(),
showInfrastructure: booleanCoercion.optional(),
customCSS: z
.string()
.max(100000, "Custom CSS must be at most 100000 characters")
.refine((css) => !cssReferencesExternalResource(css), {
message: "Custom CSS cannot reference external URLs or use @import",
})
.optional(),
removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(),
theme: z.enum(StatusPageThemes).optional(),
themeMode: z.enum(StatusPageThemeModes).optional(),
Expand Down
Loading
Loading