feat: add rss title field and handle rss link from request

This commit is contained in:
leonace924 2026-01-05 19:58:52 -05:00
parent 90fcbdc7d7
commit ac87fa1969
7 changed files with 87 additions and 16 deletions

View File

@ -202,6 +202,7 @@ async function createTables() {
table.text("footer_text"); table.text("footer_text");
table.text("custom_css"); table.text("custom_css");
table.boolean("show_powered_by").notNullable().defaultTo(true); table.boolean("show_powered_by").notNullable().defaultTo(true);
table.string("rss_title", 255);
table.string("google_analytics_tag_id"); table.string("google_analytics_tag_id");
}); });

View File

@ -0,0 +1,12 @@
exports.up = function (knex) {
return 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 { marked } = require("marked");
const { Feed } = require("feed"); const { Feed } = require("feed");
const config = require("../config"); 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"); 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,17 @@ class StatusPage extends BeanModel {
* Handle responses to RSS pages * Handle responses to RSS pages
* @param {Response} response Response object * @param {Response} response Response object
* @param {string} slug Status page slug * @param {string} slug Status page slug
* @param {Request} request Request object
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
static async handleStatusPageRSSResponse(response, slug) { static async handleStatusPageRSSResponse(response, slug, request) {
let statusPage = await R.findOne("status_page", " slug = ? ", [ let statusPage = await R.findOne("status_page", " slug = ? ", [
slug slug
]); ]);
if (statusPage) { if (statusPage) {
response.type("application/rss+xml"); response.type("application/rss+xml");
response.send(await StatusPage.renderRSS(statusPage, slug)); response.send(await StatusPage.renderRSS(statusPage, slug, request));
} else { } else {
response.status(404).send(UptimeKumaServer.getInstance().indexHTML); response.status(404).send(UptimeKumaServer.getInstance().indexHTML);
} }
@ -64,20 +66,29 @@ class StatusPage extends BeanModel {
/** /**
* SSR for RSS feed * SSR for RSS feed
* @param {statusPage} statusPage object * @param {StatusPage} statusPage Status page object
* @param {slug} slug from router * @param {string} slug Status page slug
* @returns {Promise<string>} the rendered html * @param {Request} request Express request object
* @returns {Promise<string>} The rendered RSS XML
*/ */
static async renderRSS(statusPage, slug) { static async renderRSS(statusPage, slug, request) {
const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage); const { heartbeats, statusDescription } = await StatusPage.getRSSPageData(statusPage);
let proto = config.isSSL ? "https" : "http"; // Build the feed URL, respecting proxy headers if trustProxy is enabled
let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`; const feedUrl = await StatusPage.buildRSSUrl(slug, request);
// 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({ const feed = new Feed({
title: "uptime kuma rss feed", title: feedTitle,
description: `current status: ${statusDescription}`, description: `Current status: ${statusDescription}`,
link: host, link: feedUrl,
language: "en", // optional, used only in RSS 2.0, possible values: http://www.w3.org/TR/REC-html40/struct/dirlang.html#langcodes 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 updated: new Date(), // optional, default = today
}); });
@ -85,8 +96,9 @@ class StatusPage extends BeanModel {
heartbeats.forEach(heartbeat => { heartbeats.forEach(heartbeat => {
feed.addItem({ feed.addItem({
title: `${heartbeat.name} is down`, title: `${heartbeat.name} is down`,
description: `${heartbeat.name} has been down since ${heartbeat.time}`, description: `${heartbeat.name} has been down since ${heartbeat.time} UTC`,
id: heartbeat.monitorID, id: `${heartbeat.monitorID}-${heartbeat.time}`,
link: feedUrl,
date: new Date(heartbeat.time), date: new Date(heartbeat.time),
}); });
}); });
@ -94,6 +106,38 @@ class StatusPage extends BeanModel {
return feed.rss2(); 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 * SSR for status pages
* @param {string} indexHTML HTML page to render * @param {string} indexHTML HTML page to render
@ -415,7 +459,8 @@ class StatusPage extends BeanModel {
analyticsScriptUrl: this.analytics_script_url, analyticsScriptUrl: this.analytics_script_url,
analyticsType: this.analytics_type, analyticsType: this.analytics_type,
showCertificateExpiry: !!this.show_certificate_expiry, showCertificateExpiry: !!this.show_certificate_expiry,
showOnlyLastHeartbeat: !!this.show_only_last_heartbeat showOnlyLastHeartbeat: !!this.show_only_last_heartbeat,
rssTitle: this.rss_title,
}; };
} }
@ -441,7 +486,8 @@ class StatusPage extends BeanModel {
analyticsScriptUrl: this.analytics_script_url, analyticsScriptUrl: this.analytics_script_url,
analyticsType: this.analytics_type, analyticsType: this.analytics_type,
showCertificateExpiry: !!this.show_certificate_expiry, 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) => { router.get("/status/:slug/rss", cache("5 minutes"), async (request, response) => {
let slug = request.params.slug; let slug = request.params.slug;
slug = slug.toLowerCase(); slug = slug.toLowerCase();
await StatusPage.handleStatusPageRSSResponse(response, slug); await StatusPage.handleStatusPageRSSResponse(response, slug, request);
}); });
router.get("/status", cache("5 minutes"), async (request, response) => { 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.footer_text = config.footerText;
statusPage.custom_css = config.customCSS; statusPage.custom_css = config.customCSS;
statusPage.show_powered_by = config.showPoweredBy; statusPage.show_powered_by = config.showPoweredBy;
statusPage.rss_title = config.rssTitle;
statusPage.show_only_last_heartbeat = config.showOnlyLastHeartbeat; statusPage.show_only_last_heartbeat = config.showOnlyLastHeartbeat;
statusPage.show_certificate_expiry = config.showCertificateExpiry; statusPage.show_certificate_expiry = config.showCertificateExpiry;
statusPage.modified_date = R.isoDateTime(); statusPage.modified_date = R.isoDateTime();

View File

@ -388,6 +388,8 @@
"Proxy": "Proxy", "Proxy": "Proxy",
"Date Created": "Date Created", "Date Created": "Date Created",
"Footer Text": "Footer Text", "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": "Refresh Interval",
"Refresh Interval Description": "The status page will do a full site refresh every {0} seconds", "Refresh Interval Description": "The status page will do a full site refresh every {0} seconds",
"Show Powered By": "Show Powered By", "Show Powered By": "Show Powered By",

View File

@ -34,6 +34,15 @@
</div> </div>
</div> </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>
<div class="my-3"> <div class="my-3">
<label for="auto-refresh-interval" class="form-label">{{ $t("Refresh Interval") }}</label> <label for="auto-refresh-interval" class="form-label">{{ $t("Refresh Interval") }}</label>
<input id="auto-refresh-interval" v-model="config.autoRefreshInterval" type="number" class="form-control" :min="5" data-testid="refresh-interval-input"> <input id="auto-refresh-interval" v-model="config.autoRefreshInterval" type="number" class="form-control" :min="5" data-testid="refresh-interval-input">