feat: add rss title field and handle rss link from request (#6592)

This commit is contained in:
Frank Elsinga 2026-01-06 07:19:56 +01:00 committed by GitHub
commit 1d500bb88f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 111 additions and 16 deletions

View File

@ -0,0 +1,11 @@
exports.up = async function (knex) {
await knex.schema.alterTable("status_page", function (table) {
table.string("rss_title", 255);
});
};
exports.down = function (knex) {
return knex.schema.alterTable("status_page", function (table) {
table.dropColumn("rss_title");
});
};

View File

@ -7,6 +7,7 @@ const analytics = require("../analytics/analytics");
const { marked } = require("marked");
const { Feed } = require("feed");
const config = require("../config");
const { setting } = require("../util-server");
const { STATUS_PAGE_ALL_DOWN, STATUS_PAGE_ALL_UP, STATUS_PAGE_MAINTENANCE, STATUS_PAGE_PARTIAL_DOWN, UP, MAINTENANCE, DOWN } = require("../../src/util");
@ -22,16 +23,18 @@ class StatusPage extends BeanModel {
* Handle responses to RSS pages
* @param {Response} response Response object
* @param {string} slug Status page slug
* @param {Request} request Request object
* @returns {Promise<void>}
*/
static async handleStatusPageRSSResponse(response, slug) {
static async handleStatusPageRSSResponse(response, slug, request) {
let statusPage = await R.findOne("status_page", " slug = ? ", [
slug
]);
if (statusPage) {
const feedUrl = await StatusPage.buildRSSUrl(slug, request);
response.type("application/rss+xml");
response.send(await StatusPage.renderRSS(statusPage, slug));
response.send(await StatusPage.renderRSS(statusPage, feedUrl));
} else {
response.status(404).send(UptimeKumaServer.getInstance().indexHTML);
}
@ -64,20 +67,25 @@ class StatusPage extends BeanModel {
/**
* SSR for RSS feed
* @param {statusPage} statusPage object
* @param {slug} slug from router
* @returns {Promise<string>} the rendered html
* @param {StatusPage} statusPage Status page object
* @param {string} feedUrl The URL for the RSS feed
* @returns {Promise<string>} The rendered RSS XML
*/
static async renderRSS(statusPage, slug) {
static async renderRSS(statusPage, feedUrl) {
const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage);
let proto = config.isSSL ? "https" : "http";
let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`;
// Use custom RSS title if set, otherwise fall back to status page title
let feedTitle = "Uptime Kuma RSS Feed";
if (statusPage.rss_title) {
feedTitle = statusPage.rss_title;
} else if (statusPage.title) {
feedTitle = `${statusPage.title} RSS Feed`;
}
const feed = new Feed({
title: "uptime kuma rss feed",
description: `current status: ${statusDescription}`,
link: host,
title: feedTitle,
description: `Current status: ${statusDescription}`,
link: feedUrl,
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
});
@ -85,8 +93,9 @@ class StatusPage extends BeanModel {
heartbeats.forEach(heartbeat => {
feed.addItem({
title: `${heartbeat.name} is down`,
description: `${heartbeat.name} has been down since ${heartbeat.time}`,
id: heartbeat.monitorID,
description: `${heartbeat.name} has been down since ${heartbeat.time} UTC`,
id: `${heartbeat.monitorID}-${heartbeat.time}`,
link: feedUrl,
date: new Date(heartbeat.time),
});
});
@ -94,6 +103,38 @@ class StatusPage extends BeanModel {
return feed.rss2();
}
/**
* Build RSS feed URL, handling proxy headers
* @param {string} slug Status page slug
* @param {Request} request Express request object
* @returns {Promise<string>} The full URL for the RSS feed
*/
static async buildRSSUrl(slug, request) {
if (request) {
const trustProxy = await setting("trustProxy");
// Determine protocol (check X-Forwarded-Proto if behind proxy)
let proto = request.protocol;
if (trustProxy && request.headers["x-forwarded-proto"]) {
proto = request.headers["x-forwarded-proto"].split(",")[0].trim();
}
// Determine host (check X-Forwarded-Host if behind proxy)
let host = request.get("host");
if (trustProxy && request.headers["x-forwarded-host"]) {
host = request.headers["x-forwarded-host"];
}
return `${proto}://${host}/status/${slug}`;
}
// Fallback to config values
const proto = config.isSSL ? "https" : "http";
const host = config.hostname || "localhost";
const port = config.port;
return `${proto}://${host}:${port}/status/${slug}`;
}
/**
* SSR for status pages
* @param {string} indexHTML HTML page to render
@ -415,7 +456,8 @@ class StatusPage extends BeanModel {
analyticsScriptUrl: this.analytics_script_url,
analyticsType: this.analytics_type,
showCertificateExpiry: !!this.show_certificate_expiry,
showOnlyLastHeartbeat: !!this.show_only_last_heartbeat
showOnlyLastHeartbeat: !!this.show_only_last_heartbeat,
rssTitle: this.rss_title,
};
}
@ -441,7 +483,8 @@ class StatusPage extends BeanModel {
analyticsScriptUrl: this.analytics_script_url,
analyticsType: this.analytics_type,
showCertificateExpiry: !!this.show_certificate_expiry,
showOnlyLastHeartbeat: !!this.show_only_last_heartbeat
showOnlyLastHeartbeat: !!this.show_only_last_heartbeat,
rssTitle: this.rss_title,
};
}

View File

@ -22,7 +22,7 @@ router.get("/status/:slug", cache("5 minutes"), async (request, response) => {
router.get("/status/:slug/rss", cache("5 minutes"), async (request, response) => {
let slug = request.params.slug;
slug = slug.toLowerCase();
await StatusPage.handleStatusPageRSSResponse(response, slug);
await StatusPage.handleStatusPageRSSResponse(response, slug, request);
});
router.get("/status", cache("5 minutes"), async (request, response) => {

View File

@ -164,6 +164,7 @@ module.exports.statusPageSocketHandler = (socket) => {
statusPage.footer_text = config.footerText;
statusPage.custom_css = config.customCSS;
statusPage.show_powered_by = config.showPoweredBy;
statusPage.rss_title = config.rssTitle;
statusPage.show_only_last_heartbeat = config.showOnlyLastHeartbeat;
statusPage.show_certificate_expiry = config.showCertificateExpiry;
statusPage.modified_date = R.isoDateTime();

View File

@ -388,6 +388,8 @@
"Proxy": "Proxy",
"Date Created": "Date Created",
"Footer Text": "Footer Text",
"RSS Title": "RSS Title",
"Leave blank to use status page title": "Leave blank to use status page title",
"Refresh Interval": "Refresh Interval",
"Refresh Interval Description": "The status page will do a full site refresh every {0} seconds",
"Show Powered By": "Show Powered By",

View File

@ -116,6 +116,15 @@
<input id="analyticsScriptUrl" v-model="config.analyticsScriptUrl" type="url" class="form-control" data-testid="analytics-script-url-input">
</div>
<!-- RSS Title -->
<div class="my-3">
<label for="rss-title" class="form-label">{{ $t("RSS Title") }}</label>
<input id="rss-title" v-model="config.rssTitle" type="text" class="form-control" data-testid="rss-title-input">
<div class="form-text">
{{ $t("Leave blank to use status page title") }}
</div>
</div>
<!-- Custom CSS -->
<div class="my-3">
<div class="mb-1">{{ $t("Custom CSS") }}</div>

View File

@ -281,6 +281,35 @@ test.describe("Status Page", () => {
expect(rssContent).toContain("<?xml version=\"1.0\"");
expect(rssContent).toContain("<rss");
expect(rssContent).toContain("</rss>");
// Verify RSS feed uses status page title as fallback (from issue #6217)
expect(rssContent).toContain("<title>Security Test RSS Feed</title>");
// Verify RSS link uses the correct domain (not localhost hardcoded)
expect(rssContent).toMatch(/<link>https?:\/\/[^<]+\/status\/security-test<\/link>/);
// Test custom RSS title functionality
const customRssTitle = "Custom RSS Feed Title";
await page.getByTestId("edit-button").click();
await expect(page.getByTestId("edit-sidebar")).toHaveCount(1);
await page.getByTestId("rss-title-input").fill(customRssTitle);
await page.getByTestId("save-button").click();
await expect(page.getByTestId("edit-sidebar")).toHaveCount(0);
// Fetch RSS feed again - should use custom RSS title
const rssResponseCustom = await page.request.get("/status/security-test/rss");
expect(rssResponseCustom.status()).toBe(200);
const rssContentCustom = await rssResponseCustom.text();
// Verify RSS feed uses custom title
expect(rssContentCustom).toContain(`<title>${customRssTitle}</title>`);
await testInfo.attach("rss-feed-custom-title.xml", {
body: rssContentCustom,
contentType: "application/xml"
});
await screenshot(testInfo, page);
});
});