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("custom_css");
table.boolean("show_powered_by").notNullable().defaultTo(true);
table.string("rss_title", 255);
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 { 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,17 @@ 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) {
response.type("application/rss+xml");
response.send(await StatusPage.renderRSS(statusPage, slug));
response.send(await StatusPage.renderRSS(statusPage, slug, request));
} else {
response.status(404).send(UptimeKumaServer.getInstance().indexHTML);
}
@ -64,20 +66,29 @@ 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} slug Status page slug
* @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);
let proto = config.isSSL ? "https" : "http";
let host = `${proto}://${config.hostname || "localhost"}:${config.port}/status/${slug}`;
// Build the feed URL, respecting proxy headers if trustProxy is enabled
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({
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 +96,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 +106,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 +459,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 +486,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

@ -34,6 +34,15 @@
</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">
<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">