diff options
Diffstat (limited to 'server/model/status_page.js')
-rw-r--r-- | server/model/status_page.js | 491 |
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; |