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 { Switch, Divider, Form, Input, Typography } from 'antd';
import React from 'react'; import React from 'react';
import { MonitorPicker } from '../MonitorPicker'; import { MonitorPicker } from '../MonitorPicker';
import { urlSlugValidator } from '../../../utils/validator'; import { domainValidator, urlSlugValidator } from '../../../utils/validator';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { LuMinusCircle, LuPlus } from 'react-icons/lu'; import { LuMinusCircle, LuPlus } from 'react-icons/lu';
@ -12,6 +12,7 @@ export interface MonitorStatusPageEditFormValues {
title: string; title: string;
slug: string; slug: string;
monitorList: PrismaJson.MonitorStatusPageList; monitorList: PrismaJson.MonitorStatusPageList;
domain: string;
} }
interface MonitorStatusPageEditFormProps { interface MonitorStatusPageEditFormProps {
@ -71,6 +72,25 @@ export const MonitorStatusPageEditForm: React.FC<MonitorStatusPageEditFormProps>
<Input addonBefore={`${window.origin}/status/`} /> <Input addonBefore={`${window.origin}/status/`} />
</Form.Item> </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"> <Form.List name="monitorList">
{(fields, { add, remove }, { errors }) => { {(fields, { add, remove }, { errors }) => {
return ( return (

View File

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

View File

@ -19,6 +19,7 @@ import { logger } from './utils/logger';
import { monitorRouter } from './router/monitor'; import { monitorRouter } from './router/monitor';
import { healthRouter } from './router/health'; import { healthRouter } from './router/health';
import path from 'path'; import path from 'path';
import { monitorPageManager } from './model/monitor/page/manager';
const app = express(); const app = express();
@ -27,7 +28,6 @@ app.use(express.json());
app.use(passport.initialize()); app.use(passport.initialize());
app.use(morgan('tiny')); app.use(morgan('tiny'));
app.use(cors()); 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 // http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
app.disable('x-powered-by'); app.disable('x-powered-by');
@ -60,6 +60,26 @@ if (env.allowOpenapi) {
app.use('/open', trpcOpenapiHttpHandler); 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 // fallback
app.use('/*', (req, res) => { app.use('/*', (req, res) => {
if (req.method === 'GET' && req.accepts('html')) { 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] /// [MonitorStatusPageList]
/// @zod.custom(imports.MonitorStatusPageListSchema) /// @zod.custom(imports.MonitorStatusPageListSchema)
monitorList Json @default("[]") // monitor list monitorList Json @default("[]") // monitor list
domain String? // custom domain which can add cname record
createdAt DateTime @default(now()) @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6) updatedAt DateTime @updatedAt @db.Timestamptz(6)

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { Server as HTTPServer } from 'http';
import { jwtVerify } from '../middleware/auth'; import { jwtVerify } from '../middleware/auth';
import { socketEventBus } from './shared'; import { socketEventBus } from './shared';
import { isCuid } from '../utils/common'; import { isCuid } from '../utils/common';
import { logger } from '../utils/logger';
export function initSocketio(httpServer: HTTPServer) { export function initSocketio(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, { const io = new SocketIOServer(httpServer, {
@ -30,7 +31,7 @@ export function initSocketio(httpServer: HTTPServer) {
try { try {
const user = jwtVerify(token); 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.user = user;
socket.data.token = token; socket.data.token = token;