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} */ 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} */ 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} 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} 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 = $("").attr("content", statusPage.title); head.append(ogTitle); let ogDescription = $("").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 = $(` `); 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} */ 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} 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} */ 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} 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;