summaryrefslogtreecommitdiffstats
path: root/server/model/status_page.js
diff options
context:
space:
mode:
Diffstat (limited to 'server/model/status_page.js')
-rw-r--r--server/model/status_page.js491
1 files changed, 491 insertions, 0 deletions
diff --git a/server/model/status_page.js b/server/model/status_page.js
new file mode 100644
index 0000000..38f548e
--- /dev/null
+++ b/server/model/status_page.js
@@ -0,0 +1,491 @@
+const { BeanModel } = require("redbean-node/dist/bean-model");
+const { R } = require("redbean-node");
+const cheerio = require("cheerio");
+const { UptimeKumaServer } = require("../uptime-kuma-server");
+const jsesc = require("jsesc");
+const googleAnalytics = require("../google-analytics");
+const { marked } = require("marked");
+const { Feed } = require("feed");
+const config = require("../config");
+
+const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN } = require("../../src/util");
+
+class StatusPage extends BeanModel {
+
+ /**
+ * Like this: { "test-uptime.kuma.pet": "default" }
+ * @type {{}}
+ */
+ static domainMappingList = { };
+
+ /**
+ * Handle responses to RSS pages
+ * @param {Response} response Response object
+ * @param {string} slug Status page slug
+ * @returns {Promise<void>}
+ */
+ static async handleStatusPageRSSResponse(response, slug) {
+ let statusPage = await R.findOne("status_page", " slug = ? ", [
+ slug
+ ]);
+
+ if (statusPage) {
+ response.send(await StatusPage.renderRSS(statusPage, slug));
+ } else {
+ response.status(404).send(UptimeKumaServer.getInstance().indexHTML);
+ }
+ }
+
+ /**
+ * Handle responses to status page
+ * @param {Response} response Response object
+ * @param {string} indexHTML HTML to render
+ * @param {string} slug Status page slug
+ * @returns {Promise<void>}
+ */
+ static async handleStatusPageResponse(response, indexHTML, slug) {
+ // Handle url with trailing slash (http://localhost:3001/status/)
+ // The slug comes from the route "/status/:slug". If the slug is empty, express converts it to "index.html"
+ if (slug === "index.html") {
+ slug = "default";
+ }
+
+ let statusPage = await R.findOne("status_page", " slug = ? ", [
+ slug
+ ]);
+
+ if (statusPage) {
+ response.send(await StatusPage.renderHTML(indexHTML, statusPage));
+ } else {
+ response.status(404).send(UptimeKumaServer.getInstance().indexHTML);
+ }
+ }
+
+ /**
+ * SSR for RSS feed
+ * @param {statusPage} statusPage object
+ * @param {slug} slug from router
+ * @returns {Promise<string>} the rendered html
+ */
+ static async renderRSS(statusPage, slug) {
+ const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage);
+
+ let proto = config.isSSL ? "https" : "http";
+ let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`;
+
+ const feed = new Feed({
+ title: "uptime kuma rss feed",
+ description: `current status: ${statusDescription}`,
+ link: host,
+ language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes
+ updated: new Date(), // optional, default = today
+ });
+
+ heartbeats.forEach(heartbeat => {
+ feed.addItem({
+ title: `${heartbeat.name} is down`,
+ description: `${heartbeat.name} has been down since ${heartbeat.time}`,
+ id: heartbeat.monitorID,
+ date: new Date(heartbeat.time),
+ });
+ });
+
+ return feed.rss2();
+ }
+
+ /**
+ * SSR for status pages
+ * @param {string} indexHTML HTML page to render
+ * @param {StatusPage} statusPage Status page populate HTML with
+ * @returns {Promise<string>} the rendered html
+ */
+ static async renderHTML(indexHTML, statusPage) {
+ const $ = cheerio.load(indexHTML);
+
+ const description155 = marked(statusPage.description ?? "")
+ .replace(/<[^>]+>/gm, "")
+ .trim()
+ .substring(0, 155);
+
+ $("title").text(statusPage.title);
+ $("meta[name=description]").attr("content", description155);
+
+ if (statusPage.icon) {
+ $("link[rel=icon]")
+ .attr("href", statusPage.icon)
+ .removeAttr("type");
+
+ $("link[rel=apple-touch-icon]").remove();
+ }
+
+ const head = $("head");
+
+ if (statusPage.googleAnalyticsTagId) {
+ let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.googleAnalyticsTagId);
+ head.append($(escapedGoogleAnalyticsScript));
+ }
+
+ // OG Meta Tags
+ let ogTitle = $("<meta property=\"og:title\" content=\"\" />").attr("content", statusPage.title);
+ head.append(ogTitle);
+
+ let ogDescription = $("<meta property=\"og:description\" content=\"\" />").attr("content", description155);
+ head.append(ogDescription);
+
+ // Preload data
+ // Add jsesc, fix https://github.com/louislam/uptime-kuma/issues/2186
+ const escapedJSONObject = jsesc(await StatusPage.getStatusPageData(statusPage), {
+ "isScriptContext": true
+ });
+
+ const script = $(`
+ <script id="preload-data" data-json="{}">
+ window.preloadData = ${escapedJSONObject};
+ </script>
+ `);
+
+ head.append(script);
+
+ // manifest.json
+ $("link[rel=manifest]").attr("href", `/api/status-page/${statusPage.slug}/manifest.json`);
+
+ return $.root().html();
+ }
+
+ /**
+ * @param {heartbeats} heartbeats from getRSSPageData
+ * @returns {number} status_page constant from util.ts
+ */
+ static overallStatus(heartbeats) {
+ if (heartbeats.length === 0) {
+ return -1;
+ }
+
+ let status = STATUS_PAGE_ALL_UP;
+ let hasUp = false;
+
+ for (let beat of heartbeats) {
+ if (beat.status === MAINTENANCE) {
+ return STATUS_PAGE_MAINTENANCE;
+ } else if (beat.status === UP) {
+ hasUp = true;
+ } else {
+ status = STATUS_PAGE_PARTIAL_DOWN;
+ }
+ }
+
+ if (! hasUp) {
+ status = STATUS_PAGE_ALL_DOWN;
+ }
+
+ return status;
+ }
+
+ /**
+ * @param {number} status from overallStatus
+ * @returns {string} description
+ */
+ static getStatusDescription(status) {
+ if (status === -1) {
+ return "No Services";
+ }
+
+ if (status === STATUS_PAGE_ALL_UP) {
+ return "All Systems Operational";
+ }
+
+ if (status === STATUS_PAGE_PARTIAL_DOWN) {
+ return "Partially Degraded Service";
+ }
+
+ if (status === STATUS_PAGE_ALL_DOWN) {
+ return "Degraded Service";
+ }
+
+ // TODO: show the real maintenance information: title, description, time
+ if (status === MAINTENANCE) {
+ return "Under maintenance";
+ }
+
+ return "?";
+ }
+
+ /**
+ * Get all data required for RSS
+ * @param {StatusPage} statusPage Status page to get data for
+ * @returns {object} Status page data
+ */
+ static async getRSSPageData(statusPage) {
+ // get all heartbeats that correspond to this statusPage
+ const config = await statusPage.toPublicJSON();
+
+ // Public Group List
+ const showTags = !!statusPage.show_tags;
+
+ const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [
+ statusPage.id
+ ]);
+
+ let heartbeats = [];
+
+ for (let groupBean of list) {
+ let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry);
+ for (const monitor of monitorGroup.monitorList) {
+ const heartbeat = await R.findOne("heartbeat", "monitor_id = ? ORDER BY time DESC", [ monitor.id ]);
+ if (heartbeat) {
+ heartbeats.push({
+ ...monitor,
+ status: heartbeat.status,
+ time: heartbeat.time
+ });
+ }
+ }
+ }
+
+ // calculate RSS feed description
+ let status = StatusPage.overallStatus(heartbeats);
+ let statusDescription = StatusPage.getStatusDescription(status);
+
+ // keep only DOWN heartbeats in the RSS feed
+ heartbeats = heartbeats.filter(heartbeat => heartbeat.status === DOWN);
+
+ return {
+ heartbeats,
+ statusDescription
+ };
+ }
+
+ /**
+ * Get all status page data in one call
+ * @param {StatusPage} statusPage Status page to get data for
+ * @returns {object} Status page data
+ */
+ static async getStatusPageData(statusPage) {
+ const config = await statusPage.toPublicJSON();
+
+ // Incident
+ let incident = await R.findOne("incident", " pin = 1 AND active = 1 AND status_page_id = ? ", [
+ statusPage.id,
+ ]);
+
+ if (incident) {
+ incident = incident.toPublicJSON();
+ }
+
+ let maintenanceList = await StatusPage.getMaintenanceList(statusPage.id);
+
+ // Public Group List
+ const publicGroupList = [];
+ const showTags = !!statusPage.show_tags;
+
+ const list = await R.find("group", " public = 1 AND status_page_id = ? ORDER BY weight ", [
+ statusPage.id
+ ]);
+
+ for (let groupBean of list) {
+ let monitorGroup = await groupBean.toPublicJSON(showTags, config?.showCertificateExpiry);
+ publicGroupList.push(monitorGroup);
+ }
+
+ // Response
+ return {
+ config,
+ incident,
+ publicGroupList,
+ maintenanceList,
+ };
+ }
+
+ /**
+ * Loads domain mapping from DB
+ * Return object like this: { "test-uptime.kuma.pet": "default" }
+ * @returns {Promise<void>}
+ */
+ static async loadDomainMappingList() {
+ StatusPage.domainMappingList = await R.getAssoc(`
+ SELECT domain, slug
+ FROM status_page, status_page_cname
+ WHERE status_page.id = status_page_cname.status_page_id
+ `);
+ }
+
+ /**
+ * Send status page list to client
+ * @param {Server} io io Socket server instance
+ * @param {Socket} socket Socket.io instance
+ * @returns {Promise<Bean[]>} Status page list
+ */
+ static async sendStatusPageList(io, socket) {
+ let result = {};
+
+ let list = await R.findAll("status_page", " ORDER BY title ");
+
+ for (let item of list) {
+ result[item.id] = await item.toJSON();
+ }
+
+ io.to(socket.userID).emit("statusPageList", result);
+ return list;
+ }
+
+ /**
+ * Update list of domain names
+ * @param {string[]} domainNameList List of status page domains
+ * @returns {Promise<void>}
+ */
+ async updateDomainNameList(domainNameList) {
+
+ if (!Array.isArray(domainNameList)) {
+ throw new Error("Invalid array");
+ }
+
+ let trx = await R.begin();
+
+ await trx.exec("DELETE FROM status_page_cname WHERE status_page_id = ?", [
+ this.id,
+ ]);
+
+ try {
+ for (let domain of domainNameList) {
+ if (typeof domain !== "string") {
+ throw new Error("Invalid domain");
+ }
+
+ if (domain.trim() === "") {
+ continue;
+ }
+
+ // If the domain name is used in another status page, delete it
+ await trx.exec("DELETE FROM status_page_cname WHERE domain = ?", [
+ domain,
+ ]);
+
+ let mapping = trx.dispense("status_page_cname");
+ mapping.status_page_id = this.id;
+ mapping.domain = domain;
+ await trx.store(mapping);
+ }
+ await trx.commit();
+ } catch (error) {
+ await trx.rollback();
+ throw error;
+ }
+ }
+
+ /**
+ * Get list of domain names
+ * @returns {object[]} List of status page domains
+ */
+ getDomainNameList() {
+ let domainList = [];
+ for (let domain in StatusPage.domainMappingList) {
+ let s = StatusPage.domainMappingList[domain];
+
+ if (this.slug === s) {
+ domainList.push(domain);
+ }
+ }
+ return domainList;
+ }
+
+ /**
+ * Return an object that ready to parse to JSON
+ * @returns {object} Object ready to parse
+ */
+ async toJSON() {
+ return {
+ id: this.id,
+ slug: this.slug,
+ title: this.title,
+ description: this.description,
+ icon: this.getIcon(),
+ theme: this.theme,
+ autoRefreshInterval: this.autoRefreshInterval,
+ published: !!this.published,
+ showTags: !!this.show_tags,
+ domainNameList: this.getDomainNameList(),
+ customCSS: this.custom_css,
+ footerText: this.footer_text,
+ showPoweredBy: !!this.show_powered_by,
+ googleAnalyticsId: this.google_analytics_tag_id,
+ showCertificateExpiry: !!this.show_certificate_expiry,
+ };
+ }
+
+ /**
+ * Return an object that ready to parse to JSON for public
+ * Only show necessary data to public
+ * @returns {object} Object ready to parse
+ */
+ async toPublicJSON() {
+ return {
+ slug: this.slug,
+ title: this.title,
+ description: this.description,
+ icon: this.getIcon(),
+ autoRefreshInterval: this.autoRefreshInterval,
+ theme: this.theme,
+ published: !!this.published,
+ showTags: !!this.show_tags,
+ customCSS: this.custom_css,
+ footerText: this.footer_text,
+ showPoweredBy: !!this.show_powered_by,
+ googleAnalyticsId: this.google_analytics_tag_id,
+ showCertificateExpiry: !!this.show_certificate_expiry,
+ };
+ }
+
+ /**
+ * Convert slug to status page ID
+ * @param {string} slug Status page slug
+ * @returns {Promise<number>} ID of status page
+ */
+ static async slugToID(slug) {
+ return await R.getCell("SELECT id FROM status_page WHERE slug = ? ", [
+ slug
+ ]);
+ }
+
+ /**
+ * Get path to the icon for the page
+ * @returns {string} Path
+ */
+ getIcon() {
+ if (!this.icon) {
+ return "/icon.svg";
+ } else {
+ return this.icon;
+ }
+ }
+
+ /**
+ * Get list of maintenances
+ * @param {number} statusPageId ID of status page to get maintenance for
+ * @returns {object} Object representing maintenances sanitized for public
+ */
+ static async getMaintenanceList(statusPageId) {
+ try {
+ const publicMaintenanceList = [];
+
+ let maintenanceIDList = await R.getCol(`
+ SELECT DISTINCT maintenance_id
+ FROM maintenance_status_page
+ WHERE status_page_id = ?
+ `, [ statusPageId ]);
+
+ for (const maintenanceID of maintenanceIDList) {
+ let maintenance = UptimeKumaServer.getInstance().getMaintenance(maintenanceID);
+ if (maintenance && await maintenance.isUnderMaintenance()) {
+ publicMaintenanceList.push(await maintenance.toPublicJSON());
+ }
+ }
+
+ return publicMaintenanceList;
+
+ } catch (error) {
+ return [];
+ }
+ }
+}
+
+module.exports = StatusPage;