diff --git a/server/model/status_page.js b/server/model/status_page.js index 9952d56a8..fe986fee3 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -8,6 +8,7 @@ const { marked } = require("marked"); const { Feed } = require("feed"); const config = require("../config"); const { setting } = require("../util-server"); +const { generateOGImage, getStatusColor } = require("../utils/og-image"); const { STATUS_PAGE_ALL_DOWN, @@ -106,6 +107,22 @@ class StatusPage extends BeanModel { return feed.rss2(); } + /** + * Helper function to create and append meta tags + * @param {object} $ Cheerio instance + * @param {object} head The head element + * @param {string} property The property name (or name for non-OG tags) + * @param {string} content The content value + * @param {boolean} isOG Whether this is an Open Graph tag (default: true) + * @returns {void} + * @private + */ + static _appendMetaTag($, head, property, content, isOG = true) { + const attr = isOG ? "property" : "name"; + const meta = $(``).attr(attr, property).attr("content", content); + head.append(meta); + } + /** * Build RSS feed URL, handling proxy headers * @param {string} slug Status page slug @@ -169,11 +186,30 @@ class StatusPage extends BeanModel { } // OG Meta Tags - let ogTitle = $('').attr("content", statusPage.title); - head.append(ogTitle); + StatusPage._appendMetaTag($, head, "og:title", statusPage.title); + StatusPage._appendMetaTag($, head, "og:description", description155); + StatusPage._appendMetaTag($, head, "og:type", "website"); - let ogDescription = $('').attr("content", description155); - head.append(ogDescription); + // Add og:url if primaryBaseURL is configured + const primaryBaseURL = await setting("primaryBaseURL"); + if (primaryBaseURL) { + StatusPage._appendMetaTag($, head, "og:url", `${primaryBaseURL}/status/${statusPage.slug}`); + } + + // og:image needs an absolute URL to work + if (primaryBaseURL) { + const imageUrl = `${primaryBaseURL}/api/status-page/${statusPage.slug}/image`; + StatusPage._appendMetaTag($, head, "og:image", imageUrl); + StatusPage._appendMetaTag($, head, "og:image:width", "1200"); + StatusPage._appendMetaTag($, head, "og:image:height", "630"); + StatusPage._appendMetaTag($, head, "og:image:alt", `${statusPage.title} - Status Page`); + StatusPage._appendMetaTag($, head, "twitter:image", imageUrl, false); + } + + // Twitter Card Meta Tags + StatusPage._appendMetaTag($, head, "twitter:card", "summary_large_image", false); + StatusPage._appendMetaTag($, head, "twitter:title", statusPage.title, false); + StatusPage._appendMetaTag($, head, "twitter:description", description155, false); let ogType = $(''); head.append(ogType); @@ -256,6 +292,61 @@ class StatusPage extends BeanModel { return "?"; } + /** + * Generate an Open Graph image for the status page + * @param {StatusPage} statusPage Status page object + * @returns {Promise} PNG image buffer + */ + static async generateOGImage(statusPage) { + // Get status data + const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage); + const status = StatusPage.overallStatus(heartbeats); + const statusColor = getStatusColor(status); + + // Collect monitor information for display (top monitors with their status) + const monitors = []; + + // Try to get full status page data if available (for real instances) + // This will gracefully fail for mock objects in tests + try { + if (statusPage.toPublicJSON && statusPage.id) { + const statusPageData = await StatusPage.getStatusPageData(statusPage); + if (statusPageData.publicGroupList) { + for (const group of statusPageData.publicGroupList) { + if (group.monitorList) { + for (const monitor of group.monitorList) { + monitors.push({ + name: monitor.name, + status: monitor.status || 2, // 1=up, 0=down, 2=pending + }); + } + } + } + } + } + } catch (error) { + // If getting monitor details fails, continue without them + // This allows the function to work with mock objects + } + + // Get icon - use getIcon() method if available, otherwise use icon property + const icon = + statusPage.getIcon && typeof statusPage.getIcon === "function" ? statusPage.getIcon() : statusPage.icon; + + // If no detailed monitor data, create array with count for display + const monitorsForDisplay = monitors.length > 0 ? monitors : heartbeats.map(() => ({})); + + // Generate OG image using utility function + return await generateOGImage({ + title: statusPage.title, + statusDescription, + statusColor, + icon, + showPoweredBy: !!statusPage.show_powered_by, + monitors: monitorsForDisplay, + }); + } + /** * Get all data required for RSS * @param {StatusPage} statusPage Status page to get data for diff --git a/server/routers/status-page-router.js b/server/routers/status-page-router.js index 75e8fdea8..792e5abd0 100644 --- a/server/routers/status-page-router.js +++ b/server/routers/status-page-router.js @@ -237,4 +237,31 @@ router.get("/api/status-page/:slug/badge", cache("5 minutes"), async (request, r } }); +// Status page Open Graph image +router.get("/api/status-page/:slug/image", cache("5 minutes"), async (request, response) => { + allowDevAllOrigin(response); + let slug = request.params.slug; + slug = slug.toLowerCase(); + + try { + // Get Status Page + let statusPage = await R.findOne("status_page", " slug = ? ", [slug]); + + if (!statusPage) { + sendHttpError(response, "Status Page Not Found"); + return; + } + + // Generate PNG image + const pngBuffer = await StatusPage.generateOGImage(statusPage); + + // Set appropriate headers + response.type("image/png"); + response.setHeader("Cache-Control", "public, max-age=300"); // 5 minutes + response.send(pngBuffer); + } catch (error) { + sendHttpError(response, error.message); + } +}); + module.exports = router; diff --git a/server/utils/og-image.js b/server/utils/og-image.js new file mode 100644 index 000000000..35654fb84 --- /dev/null +++ b/server/utils/og-image.js @@ -0,0 +1,320 @@ +const sharp = require("sharp"); +const fs = require("fs"); +const path = require("path"); + +const { + STATUS_PAGE_ALL_DOWN, + STATUS_PAGE_ALL_UP, + STATUS_PAGE_PARTIAL_DOWN, + MAINTENANCE, + UP, + DOWN, + PENDING, +} = require("../util-server"); + +// Image dimensions (Open Graph standard) +const OG_IMAGE_WIDTH = 1200; +const OG_IMAGE_HEIGHT = 630; + +// Display limits +const MAX_TITLE_LENGTH = 40; +const MAX_MONITOR_NAME_LENGTH = 40; +const MAX_INDIVIDUAL_MONITORS = 3; + +/** + * Get status color based on status code + * @param {number} status Status code + * @returns {string} Hex color code + */ +function getStatusColor(status) { + switch (status) { + case STATUS_PAGE_ALL_UP: + return "#10b981"; // green + case STATUS_PAGE_PARTIAL_DOWN: + return "#f59e0b"; // amber + case STATUS_PAGE_ALL_DOWN: + return "#ef4444"; // red + case MAINTENANCE: + return "#3b82f6"; // blue + default: + return "#6b7280"; // grey + } +} + +/** + * Get monitor status color + * @param {number} status Monitor status code + * @returns {string} Hex color code + */ +function getMonitorStatusColor(status) { + switch (status) { + case UP: + return "#10b981"; // green + case DOWN: + return "#ef4444"; // red + case MAINTENANCE: + return "#3b82f6"; // blue + case PENDING: + return "#f59e0b"; // amber + default: + return "#6b7280"; // grey + } +} + +/** + * Escape XML special characters + * @param {string} text Text to escape + * @returns {string} Escaped text + */ +function escapeXml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Truncate text with ellipsis if too long + * @param {string} text Text to truncate + * @param {number} maxLength Maximum length + * @returns {string} Truncated text + */ +function truncateText(text, maxLength) { + if (text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength - 3) + "..."; +} + +/** + * Count monitors by status + * @param {Array} monitors Array of monitor objects + * @returns {object} Status counts + */ +function countMonitorsByStatus(monitors) { + const counts = { up: 0, down: 0, pending: 0, maintenance: 0 }; + + monitors.forEach((monitor) => { + if (monitor.status === UP) { + counts.up++; + } else if (monitor.status === DOWN) { + counts.down++; + } else if (monitor.status === PENDING) { + counts.pending++; + } else if (monitor.status === MAINTENANCE) { + counts.maintenance++; + } + }); + + return counts; +} + +/** + * Generate SVG for monitor status summary (when more than 3 monitors) + * @param {object} statusCounts Status counts object + * @param {number} startY Starting Y coordinate + * @returns {string} SVG markup + */ +function generateMonitorStatusSummary(statusCounts, startY) { + let y = startY; + let svg = ""; + + if (statusCounts.up > 0) { + svg += ` + + ${statusCounts.up} Up`; + y += 35; + } + + if (statusCounts.down > 0) { + svg += ` + + ${statusCounts.down} Down`; + y += 35; + } + + if (statusCounts.maintenance > 0) { + svg += ` + + ${statusCounts.maintenance} Maintenance`; + y += 35; + } + + if (statusCounts.pending > 0) { + svg += ` + + ${statusCounts.pending} Pending`; + } + + return svg; +} + +/** + * Generate SVG for individual monitor details (3 or fewer monitors) + * @param {Array} monitors Array of monitor objects + * @param {number} startY Starting Y coordinate + * @returns {string} SVG markup + */ +function generateIndividualMonitorDetails(monitors, startY) { + let svg = ""; + const displayMonitors = monitors.slice(0, MAX_INDIVIDUAL_MONITORS); + + displayMonitors.forEach((monitor, index) => { + const y = startY + index * 35; + const statusColor = getMonitorStatusColor(monitor.status); + const monitorName = truncateText(monitor.name, MAX_MONITOR_NAME_LENGTH); + + svg += ` + + ${escapeXml(monitorName)}`; + }); + + return svg; +} + +/** + * Generate monitor details section based on monitor data + * @param {Array} monitors Array of monitor objects + * @returns {string} SVG markup + */ +function generateMonitorDetailsSection(monitors) { + const startY = 420; + + // No monitors or monitors without names - show count + if (monitors.length === 0 || !monitors[0].name) { + const plural = monitors.length !== 1 ? "s" : ""; + return ` + ${monitors.length} Monitor${plural} tracked`; + } + + // Show summary for more than 3 monitors + if (monitors.length > MAX_INDIVIDUAL_MONITORS) { + const statusCounts = countMonitorsByStatus(monitors); + return generateMonitorStatusSummary(statusCounts, startY); + } + + // Show individual monitors for 3 or fewer + return generateIndividualMonitorDetails(monitors, startY); +} + +/** + * Load and embed icon SVG + * @param {string|null} icon Icon URL or path + * @returns {string} SVG markup for icon + */ +function generateIconSection(icon) { + if (!icon) { + return ""; + } + + try { + let filePath = ""; + + if (icon.startsWith("/upload/")) { + filePath = path.join(__dirname, "../../data", icon); + } else { + filePath = path.join(__dirname, "../../public/icon.svg"); + } + + if (!fs.existsSync(filePath) || !icon.endsWith(".svg")) { + return ""; + } + + const iconContent = fs.readFileSync(filePath, "utf8"); + const svgMatch = iconContent.match(/]*>([\s\S]*)<\/svg>/i); + + if (!svgMatch) { + return ""; + } + + // Embed icon scaled to ~80px at top right + return ` + + + ${svgMatch[1]} + `; + } catch (error) { + // Silently fail - OG image will generate without icon + return ""; + } +} + +/** + * Generate SVG for Open Graph image + * @param {string} title Status page title + * @param {string} statusDescription Status description text + * @param {string} statusColor Status color hex code + * @param {string|null} icon Custom icon URL + * @param {boolean} showPoweredBy Whether to show "Powered by Uptime Kuma" branding + * @param {number} timestamp Last updated timestamp (Unix timestamp in milliseconds) + * @param {Array} monitors Array of monitor objects with name and status + * @returns {string} SVG markup + */ +function generateOGImageSVG(title, statusDescription, statusColor, icon, showPoweredBy, timestamp, monitors) { + const displayTitle = truncateText(title, MAX_TITLE_LENGTH); + const iconSVG = generateIconSection(icon); + const monitorDetailsSVG = generateMonitorDetailsSection(monitors); + + // Footer + const date = new Date(timestamp); + const timestampText = `UPDATED AT ${date.toISOString()}`; + let footerSVG = ""; + + if (showPoweredBy) { + footerSVG += ` + POWERED BY UPTIME KUMA`; + } + + footerSVG += ` + ${timestampText}`; + + return ` + + + + + + +${iconSVG} + + ${escapeXml(displayTitle)} + + + + + + ${escapeXml(statusDescription)} + +${monitorDetailsSVG}${footerSVG} +`; +} + +/** + * Generate an Open Graph image for the status page + * @param {object} statusPageData Status page data object containing title, status info, monitors, etc. + * @param {string} statusPageData.title Status page title + * @param {string} statusPageData.statusDescription Status description text + * @param {string} statusPageData.statusColor Status color hex code + * @param {string|null} statusPageData.icon Custom icon URL + * @param {boolean} statusPageData.showPoweredBy Whether to show "Powered by Uptime Kuma" branding + * @param {Array} statusPageData.monitors Array of monitor objects + * @returns {Promise} PNG image buffer + */ +async function generateOGImage(statusPageData) { + const { title, statusDescription, statusColor, icon, showPoweredBy, monitors } = statusPageData; + + const svg = generateOGImageSVG(title, statusDescription, statusColor, icon, !!showPoweredBy, Date.now(), monitors); + + const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer(); + + return pngBuffer; +} + +module.exports = { + generateOGImageSVG, + generateOGImage, + getStatusColor, + escapeXml, +}; diff --git a/test/backend-test/snapshots/og-images/all-down.svg b/test/backend-test/snapshots/og-images/all-down.svg new file mode 100644 index 000000000..ed9c8533a --- /dev/null +++ b/test/backend-test/snapshots/og-images/all-down.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + Infrastructure + + + + + + + + Degraded Service + + + + + + 5 Up + + + + 1 Pending + + POWERED BY UPTIME KUMA + UPDATED AT 2024-01-01T00:00:00.000Z + \ No newline at end of file diff --git a/test/backend-test/snapshots/og-images/all-up-custom-icon-no-branding.svg b/test/backend-test/snapshots/og-images/all-up-custom-icon-no-branding.svg new file mode 100644 index 000000000..068ad7cfb --- /dev/null +++ b/test/backend-test/snapshots/og-images/all-up-custom-icon-no-branding.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + Production + + + + + + + + All Systems Operational + + + + + + 6 Up + + + + 2 Pending + + UPDATED AT 2024-01-01T00:00:00.000Z + \ No newline at end of file diff --git a/test/backend-test/snapshots/og-images/all-up-custom-icon-with-branding.svg b/test/backend-test/snapshots/og-images/all-up-custom-icon-with-branding.svg new file mode 100644 index 000000000..2accd549c --- /dev/null +++ b/test/backend-test/snapshots/og-images/all-up-custom-icon-with-branding.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + Production + + + + + + + + All Systems Operational + + + + + + 6 Up + + + + 2 Pending + + POWERED BY UPTIME KUMA + UPDATED AT 2024-01-01T00:00:00.000Z + \ No newline at end of file diff --git a/test/backend-test/snapshots/og-images/all-up-long-title.svg b/test/backend-test/snapshots/og-images/all-up-long-title.svg new file mode 100644 index 000000000..e23cbe514 --- /dev/null +++ b/test/backend-test/snapshots/og-images/all-up-long-title.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + This is an extremely long status page... + + + + + + + + All Systems Operational + + + + + + 4 Up + + + + 1 Pending + + POWERED BY UPTIME KUMA + UPDATED AT 2024-01-01T00:00:00.000Z + \ No newline at end of file diff --git a/test/backend-test/snapshots/og-images/all-up-many-monitors.svg b/test/backend-test/snapshots/og-images/all-up-many-monitors.svg new file mode 100644 index 000000000..6f9a4ebce --- /dev/null +++ b/test/backend-test/snapshots/og-images/all-up-many-monitors.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + Enterprise + + + + + + + + All Systems Operational + + + + + + 170 Up + + + + 20 Down + + + + 6 Maintenance + + + + 4 Pending + + POWERED BY UPTIME KUMA + UPDATED AT 2024-01-01T00:00:00.000Z + \ No newline at end of file diff --git a/test/backend-test/snapshots/og-images/all-up-no-branding.svg b/test/backend-test/snapshots/og-images/all-up-no-branding.svg new file mode 100644 index 000000000..835b07444 --- /dev/null +++ b/test/backend-test/snapshots/og-images/all-up-no-branding.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + Production + + + + + + + + All Systems Operational + + + + + + 6 Up + + + + 2 Pending + + UPDATED AT 2024-01-01T00:00:00.000Z + \ No newline at end of file diff --git a/test/backend-test/snapshots/og-images/all-up.svg b/test/backend-test/snapshots/og-images/all-up.svg new file mode 100644 index 000000000..257679f05 --- /dev/null +++ b/test/backend-test/snapshots/og-images/all-up.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + Production + + + + + + + + All Systems Operational + + + + + + 6 Up + + + + 2 Pending + + POWERED BY UPTIME KUMA + UPDATED AT 2024-01-01T00:00:00.000Z + \ No newline at end of file diff --git a/test/backend-test/snapshots/og-images/maintenance.svg b/test/backend-test/snapshots/og-images/maintenance.svg new file mode 100644 index 000000000..005431835 --- /dev/null +++ b/test/backend-test/snapshots/og-images/maintenance.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + Maintenance + + + + + + + + Under maintenance + + + + + 3 Monitors tracked + + POWERED BY UPTIME KUMA + UPDATED AT 2024-01-01T00:00:00.000Z + \ No newline at end of file diff --git a/test/backend-test/snapshots/og-images/partial.svg b/test/backend-test/snapshots/og-images/partial.svg new file mode 100644 index 000000000..8c11a743c --- /dev/null +++ b/test/backend-test/snapshots/og-images/partial.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + API Services + + + + + + + + Partially Degraded Service + + + + + + 10 Up + + + + 1 Down + + + + 1 Pending + + POWERED BY UPTIME KUMA + UPDATED AT 2024-01-01T00:00:00.000Z + \ No newline at end of file diff --git a/test/backend-test/test-status-page-og-image.js b/test/backend-test/test-status-page-og-image.js new file mode 100644 index 000000000..39968707e --- /dev/null +++ b/test/backend-test/test-status-page-og-image.js @@ -0,0 +1,365 @@ +const { describe, test } = require("node:test"); +const assert = require("node:assert"); +const sharp = require("sharp"); +const StatusPage = require("../../server/model/status_page"); +const { generateOGImageSVG, getStatusColor, escapeXml } = require("../../server/utils/og-image"); +const fs = require("fs"); +const path = require("path"); +const { STATUS_PAGE_ALL_UP, STATUS_PAGE_PARTIAL_DOWN, STATUS_PAGE_ALL_DOWN, MAINTENANCE } = require("../../src/util"); + +const SNAPSHOTS_DIR = path.join(__dirname, "snapshots", "og-images"); + +// Ensure snapshots directory exists +if (!fs.existsSync(SNAPSHOTS_DIR)) { + fs.mkdirSync(SNAPSHOTS_DIR, { recursive: true }); +} + +// Fixed timestamp for consistent snapshots (2024-01-01 00:00:00 UTC) +const FIXED_TIMESTAMP = 1704067200000; + +const TEST_SCENARIOS = [ + { + name: "all-up", + title: "Production", + statusCode: STATUS_PAGE_ALL_UP, + monitorCount: 8, + }, + { + name: "all-down", + title: "Infrastructure", + statusCode: STATUS_PAGE_ALL_DOWN, + monitorCount: 6, + }, + { + name: "maintenance", + title: "Maintenance", + statusCode: MAINTENANCE, + monitorCount: 3, + }, + { + name: "partial", + title: "API Services", + statusCode: STATUS_PAGE_PARTIAL_DOWN, + monitorCount: 12, + }, + { + name: "all-up-many-monitors", + title: "Enterprise", + statusCode: STATUS_PAGE_ALL_UP, + monitorCount: 200, + }, + { + name: "all-up-no-branding", + title: "Production", + statusCode: STATUS_PAGE_ALL_UP, + monitorCount: 8, + showPoweredBy: false, + }, + { + name: "all-up-custom-icon-no-branding", + title: "Production", + statusCode: STATUS_PAGE_ALL_UP, + monitorCount: 8, + icon: "/icon.svg", + showPoweredBy: false, + }, + { + name: "all-up-custom-icon-with-branding", + title: "Production", + statusCode: STATUS_PAGE_ALL_UP, + monitorCount: 8, + icon: "/icon.svg", + showPoweredBy: true, + }, + { + name: "all-up-long-title", + title: "This is an extremely long status page title that will need to be truncated", + statusCode: STATUS_PAGE_ALL_UP, + monitorCount: 5, + }, +]; + +/** + * Compare SVG with snapshot, create if doesn't exist + * @param {string} svg SVG content + * @param {string} snapshotName Snapshot filename + * @returns {void} + */ +function assertMatchesSnapshot(svg, snapshotName) { + const snapshotPath = path.join(SNAPSHOTS_DIR, snapshotName); + + if (fs.existsSync(snapshotPath)) { + const snapshot = fs.readFileSync(snapshotPath, "utf8"); + assert.strictEqual(svg, snapshot, `Expected SVG to match snapshot: ${snapshotName}`); + } else { + fs.writeFileSync(snapshotPath, svg, "utf8"); + console.log(`Created snapshot: ${snapshotName}`); + } +} + +/** + * Creates a mock status page with RSS data + * @param {object} overrides Override default values + * @param {object} rssData RSS data to return + * @returns {object} Mock status page and cleanup function + */ +function createMockStatusPage(overrides = {}, rssData = null) { + const mockPage = { + id: 1, + slug: "test", + title: "Test Page", + description: "Test Description", + ...overrides, + }; + + const originalGetRSSData = StatusPage.getRSSPageData; + + if (rssData) { + StatusPage.getRSSPageData = async () => rssData; + } + + const cleanup = () => { + StatusPage.getRSSPageData = originalGetRSSData; + }; + + return { mockPage, cleanup }; +} + +/** + * Verify PNG buffer structure and metadata + * @param {Buffer} buffer PNG buffer to verify + * @returns {Promise} + */ +async function assertValidPNG(buffer) { + assert.ok(buffer instanceof Buffer, "Expected Buffer instance"); + assert.ok(buffer.length > 0, "Expected non-empty buffer"); + + // Check PNG signature (first 8 bytes) + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const actualSignature = buffer.slice(0, 8); + assert.deepStrictEqual(actualSignature, pngSignature, "Expected valid PNG signature"); + + // Use sharp to verify metadata + const metadata = await sharp(buffer).metadata(); + assert.strictEqual(metadata.format, "png", "Expected PNG format"); + assert.strictEqual(metadata.width, 1200, "Expected width of 1200"); + assert.strictEqual(metadata.height, 630, "Expected height of 630"); +} + +describe("OG Image Helper Functions", () => { + describe("escapeXml()", () => { + test("escapeXml() replaces ampersand with entity", () => { + assert.strictEqual(escapeXml("&"), "&"); + }); + + test("escapeXml() replaces less-than with entity", () => { + assert.strictEqual(escapeXml("<"), "<"); + }); + + test("escapeXml() replaces greater-than with entity", () => { + assert.strictEqual(escapeXml(">"), ">"); + }); + + test("escapeXml() replaces double quote with entity", () => { + assert.strictEqual(escapeXml('"'), """); + }); + + test("escapeXml() replaces single quote with entity", () => { + assert.strictEqual(escapeXml("'"), "'"); + }); + + test("escapeXml() handles multiple special characters in one string", () => { + const input = "Company & Services \"Test\" 'Quote'"; + const expected = "Company & Services <Status> "Test" 'Quote'"; + assert.strictEqual(escapeXml(input), expected); + }); + }); + + describe("getStatusColor()", () => { + test("getStatusColor() returns green when all systems are up", () => { + assert.strictEqual(getStatusColor(STATUS_PAGE_ALL_UP), "#10b981"); + }); + + test("getStatusColor() returns yellow when service is partially degraded", () => { + assert.strictEqual(getStatusColor(STATUS_PAGE_PARTIAL_DOWN), "#f59e0b"); + }); + + test("getStatusColor() returns red when all systems are down", () => { + assert.strictEqual(getStatusColor(STATUS_PAGE_ALL_DOWN), "#ef4444"); + }); + + test("getStatusColor() returns blue when under maintenance", () => { + assert.strictEqual(getStatusColor(MAINTENANCE), "#3b82f6"); + }); + + test("getStatusColor() returns gray when there are no services", () => { + assert.strictEqual(getStatusColor(-1), "#6b7280"); + }); + }); +}); + +describe("generateOGImageSVG()", () => { + test("generateOGImageSVG() starts with XML declaration", () => { + const svg = generateOGImageSVG("Test", "All OK", "#10b981", null, true, FIXED_TIMESTAMP, Array(5).fill({})); + + assert.ok(svg.startsWith(" { + const svg = generateOGImageSVG("Test", "All OK", "#10b981", null, true, FIXED_TIMESTAMP, Array(5).fill({})); + + assert.ok(svg.includes('xmlns="http://www.w3.org/2000/svg"'), "Expected SVG namespace"); + }); + + test("generateOGImageSVG() sets width to 1200", () => { + const svg = generateOGImageSVG("Test", "All OK", "#10b981", null, true, FIXED_TIMESTAMP, Array(5).fill({})); + + assert.ok(svg.includes('width="1200"'), "Expected width of 1200"); + }); + + test("generateOGImageSVG() sets height to 630", () => { + const svg = generateOGImageSVG("Test", "All OK", "#10b981", null, true, FIXED_TIMESTAMP, Array(5).fill({})); + + assert.ok(svg.includes('height="630"'), "Expected height of 630"); + }); + + test("generateOGImageSVG() includes closing SVG tag", () => { + const svg = generateOGImageSVG("Test", "All OK", "#10b981", null, true, FIXED_TIMESTAMP, Array(5).fill({})); + + assert.ok(svg.includes(""), "Expected closing svg tag"); + }); + + test("generateOGImageSVG() generates matching snapshots for all test scenarios", () => { + TEST_SCENARIOS.forEach((scenario) => { + const statusDescription = StatusPage.getStatusDescription(scenario.statusCode); + const statusColor = getStatusColor(scenario.statusCode); + const icon = scenario.icon || null; + const showPoweredBy = scenario.showPoweredBy !== undefined ? scenario.showPoweredBy : true; + + // For scenarios with many monitors (>3), show status count summary + // Otherwise show individual monitors or total count + let monitors; + if (scenario.monitorCount > 3) { + // Create realistic monitor mix for status summary + monitors = []; + const upCount = Math.floor(scenario.monitorCount * 0.85); // 85% up + const downCount = Math.floor(scenario.monitorCount * 0.1); // 10% down + const maintenanceCount = Math.floor(scenario.monitorCount * 0.03); // 3% maintenance + const pendingCount = scenario.monitorCount - upCount - downCount - maintenanceCount; // Rest pending + + for (let i = 0; i < upCount; i++) { + monitors.push({ name: `Monitor ${i + 1}`, status: 1 }); + } + for (let i = 0; i < downCount; i++) { + monitors.push({ name: `Monitor ${upCount + i + 1}`, status: 0 }); + } + for (let i = 0; i < maintenanceCount; i++) { + monitors.push({ name: `Monitor ${upCount + downCount + i + 1}`, status: 3 }); + } + for (let i = 0; i < pendingCount; i++) { + monitors.push({ name: `Monitor ${upCount + downCount + maintenanceCount + i + 1}`, status: 2 }); + } + } else { + monitors = Array(scenario.monitorCount).fill({}); // Show count + } + + const svg = generateOGImageSVG( + scenario.title, + statusDescription, + statusColor, + icon, + showPoweredBy, + FIXED_TIMESTAMP, + monitors + ); + assertMatchesSnapshot(svg, `${scenario.name}.svg`); + }); + }); +}); + +describe("StatusPage Model", () => { + describe("overallStatus()", () => { + test("overallStatus() returns -1 when heartbeats array is empty", () => { + const status = StatusPage.overallStatus([]); + assert.strictEqual(status, -1); + }); + + test("overallStatus() returns ALL_UP when all monitors are up", () => { + const status = StatusPage.overallStatus([{ status: 1 }, { status: 1 }]); + assert.strictEqual(status, STATUS_PAGE_ALL_UP); + }); + + test("overallStatus() returns PARTIAL_DOWN when monitors have mixed statuses", () => { + const status = StatusPage.overallStatus([{ status: 1 }, { status: 0 }]); + assert.strictEqual(status, STATUS_PAGE_PARTIAL_DOWN); + }); + + test("overallStatus() returns ALL_DOWN when all monitors are down", () => { + const status = StatusPage.overallStatus([{ status: 0 }, { status: 0 }]); + assert.strictEqual(status, STATUS_PAGE_ALL_DOWN); + }); + }); + + describe("getStatusDescription()", () => { + test("getStatusDescription() returns 'No Services' when status is -1", () => { + assert.strictEqual(StatusPage.getStatusDescription(-1), "No Services"); + }); + + test("getStatusDescription() returns 'All Systems Operational' when all systems are up", () => { + assert.strictEqual(StatusPage.getStatusDescription(STATUS_PAGE_ALL_UP), "All Systems Operational"); + }); + + test("getStatusDescription() returns 'Partially Degraded Service' when service is partially down", () => { + assert.strictEqual(StatusPage.getStatusDescription(STATUS_PAGE_PARTIAL_DOWN), "Partially Degraded Service"); + }); + + test("getStatusDescription() returns 'Degraded Service' when all systems are down", () => { + assert.strictEqual(StatusPage.getStatusDescription(STATUS_PAGE_ALL_DOWN), "Degraded Service"); + }); + + test("getStatusDescription() returns 'Under maintenance' when under maintenance", () => { + assert.strictEqual(StatusPage.getStatusDescription(MAINTENANCE), "Under maintenance"); + }); + }); + describe("StatusPage.generateOGImage()", () => { + test("generateOGImage() generates PNG between 1KB and 100KB", async () => { + const { mockPage, cleanup } = createMockStatusPage( + {}, + { + heartbeats: [{ status: 1 }], + statusDescription: "All Systems Operational", + } + ); + + try { + const buffer = await StatusPage.generateOGImage(mockPage); + await assertValidPNG(buffer); + assert.ok(buffer.length > 1000, "Expected PNG to be greater than 1KB"); + assert.ok(buffer.length < 100 * 1024, "Expected PNG to be less than 100KB"); + } finally { + cleanup(); + } + }); + + test("generateOGImage() handles status page with custom icon and no branding", async () => { + const { mockPage, cleanup } = createMockStatusPage( + { + title: "Production", + show_powered_by: false, + icon: "/icon.svg", + }, + { + heartbeats: [{ status: 1 }, { status: 0 }], + statusDescription: "Partially Degraded Service", + } + ); + + try { + const buffer = await StatusPage.generateOGImage(mockPage); + await assertValidPNG(buffer); + } finally { + cleanup(); + } + }); + }); +}); diff --git a/test/e2e/specs/status-page.spec.js b/test/e2e/specs/status-page.spec.js index 60de8ef1a..600f854bf 100644 --- a/test/e2e/specs/status-page.spec.js +++ b/test/e2e/specs/status-page.spec.js @@ -210,6 +210,43 @@ test.describe("Status Page", () => { expect(await page.locator("head").innerHTML()).toContain(matomoSiteId); }); + test("OG image and meta tags", async ({ page, request }) => { + test.setTimeout(60000); + + await page.goto("./dashboard"); + await login(page); + + // Navigate to default status page + await page.goto("./status/default"); + await page.waitForTimeout(1000); + + // Verify OG meta tags exist + const ogTitle = await page.locator("meta[property='og:title']").getAttribute("content"); + expect(ogTitle).toBeTruthy(); + + const ogType = await page.locator("meta[property='og:type']").getAttribute("content"); + expect(ogType).toBe("website"); + + const twitterCard = await page.locator("meta[name='twitter:card']").getAttribute("content"); + expect(twitterCard).toBe("summary_large_image"); + + // Test OG image API endpoint + const imageResponse = await request.get("/api/status-page/default/image"); + expect(imageResponse.status()).toBe(200); + expect(imageResponse.headers()["content-type"]).toBe("image/png"); + + const buffer = await imageResponse.body(); + expect(buffer.length).toBeGreaterThan(0); + + // Verify PNG signature + const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect(buffer.subarray(0, 8)).toEqual(pngSignature); + + // Test 404 for non-existent page + const notFoundResponse = await request.get("/api/status-page/does-not-exist/image"); + expect(notFoundResponse.status()).toBe(404); + }); + // @todo Test certificate expiry // @todo Test domain names