feat: add custom domain support for status page

This commit is contained in:
moonrailgun 2024-04-18 00:20:01 +08:00
parent f0bd4dd993
commit a73720411c
10 changed files with 192 additions and 10 deletions

View File

@ -1,7 +1,7 @@
import { Switch, Divider, Form, Input, Typography } from 'antd';
import React from 'react';
import { MonitorPicker } from '../MonitorPicker';
import { urlSlugValidator } from '../../../utils/validator';
import { domainValidator, urlSlugValidator } from '../../../utils/validator';
import { useTranslation } from '@i18next-toolkit/react';
import { Button } from '@/components/ui/button';
import { LuMinusCircle, LuPlus } from 'react-icons/lu';
@ -12,6 +12,7 @@ export interface MonitorStatusPageEditFormValues {
title: string;
slug: string;
monitorList: PrismaJson.MonitorStatusPageList;
domain: string;
}
interface MonitorStatusPageEditFormProps {
@ -71,6 +72,25 @@ export const MonitorStatusPageEditForm: React.FC<MonitorStatusPageEditFormProps>
<Input addonBefore={`${window.origin}/status/`} />
</Form.Item>
<Form.Item
label={t('Custom Domain')}
name="domain"
extra={
<div>
{t(
'You can config your status page in your own domain, for example: status.example.com'
)}
</div>
}
rules={[
{
validator: domainValidator,
},
]}
>
<Input />
</Form.Item>
<Form.List name="monitorList">
{(fields, { add, remove }, { errors }) => {
return (

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { trpc } from '../../../api/trpc';
import { useAllowEdit } from './useAllowEdit';
import {
@ -38,6 +38,17 @@ export const MonitorStatusPage: React.FC<MonitorStatusPageProps> = React.memo(
const monitorList = info?.monitorList ?? [];
const initialValues = useMemo(() => {
if (!info) {
return {};
}
return {
...info,
domain: info.domain ?? '',
};
}, [info]);
const [{ loading }, handleSave] = useRequest(
async (values: MonitorStatusPageEditFormValues) => {
if (!info) {
@ -84,7 +95,7 @@ export const MonitorStatusPage: React.FC<MonitorStatusPageProps> = React.memo(
<div className="w-1/3 overflow-auto border-r border-gray-300 px-4 py-8 dark:border-gray-600">
<MonitorStatusPageEditForm
isLoading={loading}
initialValues={info ?? {}}
initialValues={initialValues}
onFinish={handleSave}
onCancel={() => setEditMode(false)}
/>

View File

@ -17,6 +17,15 @@ export const hostnameValidator: Validator = (rule, value, callback) => {
}
};
export const domainValidator: Validator = (rule, value, callback) => {
try {
z.string().regex(hostnameRegex).parse(value);
callback();
} catch (err) {
callback('Not valid, it should be domain, for example: example.com');
}
};
export const urlSlugValidator: Validator = (rule, value, callback) => {
try {
z.string().regex(slugRegex).parse(value);

View File

@ -19,6 +19,7 @@ import { logger } from './utils/logger';
import { monitorRouter } from './router/monitor';
import { healthRouter } from './router/health';
import path from 'path';
import { monitorPageManager } from './model/monitor/page/manager';
const app = express();
@ -27,7 +28,6 @@ app.use(express.json());
app.use(passport.initialize());
app.use(morgan('tiny'));
app.use(cors());
app.use(express.static('public'));
// http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable('x-powered-by');
@ -60,6 +60,26 @@ if (env.allowOpenapi) {
app.use('/open', trpcOpenapiHttpHandler);
}
// Custom Status Page
app.use('/*', (req, res, next) => {
if (req.baseUrl === '/' || req.baseUrl === '') {
const customDomain = monitorPageManager.findPageDomain(req.hostname);
if (customDomain) {
res
.status(200)
.send(
`<body style="padding: 0; margin: 0;"><iframe style="border:none; width: 100%; height: 100%;" title="" src="/status/${customDomain.slug}" /></body>`
);
return;
}
}
next();
});
// Static
app.use(express.static('public'));
// fallback
app.use('/*', (req, res) => {
if (req.method === 'GET' && req.accepts('html')) {

View File

@ -0,0 +1,84 @@
import { logger } from '../../../utils/logger';
import { prisma } from '../../_client';
class MonitorPageManager {
private customDomainPage: Record<
string,
{
workspaceId: string;
pageId: string;
slug: string;
}
> = {}; // key: domain
constructor() {
prisma.monitorStatusPage
.findMany({
where: {
domain: {
not: null,
},
},
select: {
id: true,
domain: true,
workspaceId: true,
slug: true,
},
})
.then((res) => {
res.forEach((item) => {
if (!item.domain) {
return;
}
this.customDomainPage[item.domain] = {
pageId: item.id,
workspaceId: item.workspaceId,
slug: item.slug,
};
});
logger.info(`Loaded ${res.length} custom domain for status page`);
})
.catch((err) => {
logger.error('Cannot load monitor page domain list:', err);
});
}
/**
* check domain existed
* if domain not been used, return true
*/
async checkDomain(domain: string) {
const res = await prisma.monitorStatusPage.findFirst({
where: {
domain,
},
});
return !res;
}
async updatePageDomain(
domain: string,
info: {
workspaceId: string;
pageId: string;
slug: string;
}
) {
this.customDomainPage[domain] = info;
logger.info(`Update page domain: ${domain} to page ${info.pageId}`);
}
findPageDomain(domain: string) {
if (!this.customDomainPage[domain]) {
return null;
}
return this.customDomainPage[domain];
}
}
export const monitorPageManager = new MonitorPageManager();

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "MonitorStatusPage" ADD COLUMN "domain" TEXT;

View File

@ -355,6 +355,7 @@ model MonitorStatusPage {
/// [MonitorStatusPageList]
/// @zod.custom(imports.MonitorStatusPageListSchema)
monitorList Json @default("[]") // monitor list
domain String? // custom domain which can add cname record
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)

View File

@ -18,6 +18,7 @@ export const MonitorStatusPageModelSchema = z.object({
* [MonitorStatusPageList]
*/
monitorList: imports.MonitorStatusPageListSchema,
domain: z.string().nullish(),
createdAt: z.date(),
updatedAt: z.date(),
})

View File

@ -31,6 +31,7 @@ import {
MonitorInfoWithNotificationIds,
MonitorPublicInfoSchema,
} from '../../model/_schema/monitor';
import { monitorPageManager } from '../../model/monitor/page/manager';
export const monitorRouter = router({
all: workspaceProcedure
@ -599,12 +600,14 @@ export const monitorRouter = router({
MonitorStatusPageModelSchema.pick({
description: true,
monitorList: true,
domain: true,
}).partial()
)
)
.output(MonitorStatusPageModelSchema)
.mutation(async ({ input }) => {
const { workspaceId, slug, title, description, monitorList } = input;
const { workspaceId, slug, title, description, monitorList, domain } =
input;
const existSlugCount = await prisma.monitorStatusPage.count({
where: {
@ -616,17 +619,30 @@ export const monitorRouter = router({
throw new Error('This slug has been existed');
}
const res = await prisma.monitorStatusPage.create({
if (domain && !(await monitorPageManager.checkDomain(domain))) {
throw new Error('This domain has been used');
}
const page = await prisma.monitorStatusPage.create({
data: {
workspaceId,
slug,
title,
description,
monitorList,
domain: domain || null, // make sure not ''
},
});
return res;
if (page.domain) {
monitorPageManager.updatePageDomain(page.domain, {
workspaceId: page.workspaceId,
pageId: page.id,
slug: page.slug,
});
}
return page;
}),
editPage: workspaceOwnerProcedure
.meta(
@ -644,12 +660,14 @@ export const monitorRouter = router({
title: true,
description: true,
monitorList: true,
domain: true,
}).partial()
)
)
.output(MonitorStatusPageModelSchema)
.mutation(async ({ input }) => {
const { id, workspaceId, slug, title, description, monitorList } = input;
const { id, workspaceId, slug, title, description, monitorList, domain } =
input;
if (slug) {
const existSlugCount = await prisma.monitorStatusPage.count({
@ -666,7 +684,11 @@ export const monitorRouter = router({
}
}
return prisma.monitorStatusPage.update({
if (domain && !(await monitorPageManager.checkDomain(domain))) {
throw new Error('This domain has been used');
}
const page = await prisma.monitorStatusPage.update({
where: {
id,
workspaceId,
@ -676,8 +698,19 @@ export const monitorRouter = router({
title,
description,
monitorList,
domain: domain || null,
},
});
if (page.domain) {
monitorPageManager.updatePageDomain(page.domain, {
workspaceId: page.workspaceId,
pageId: page.id,
slug: page.slug,
});
}
return page;
}),
deletePage: workspaceOwnerProcedure
.meta(

View File

@ -3,6 +3,7 @@ import { Server as HTTPServer } from 'http';
import { jwtVerify } from '../middleware/auth';
import { socketEventBus } from './shared';
import { isCuid } from '../utils/common';
import { logger } from '../utils/logger';
export function initSocketio(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, {
@ -30,7 +31,7 @@ export function initSocketio(httpServer: HTTPServer) {
try {
const user = jwtVerify(token);
console.log('[Socket] Authenticated via JWT:', user.username);
logger.info('[Socket] Authenticated via JWT:', user.username);
socket.data.user = user;
socket.data.token = token;