feat: add rss title field and handle rss link from request (#6592)
This commit is contained in:
commit
1d500bb88f
11
db/knex_migrations/2026-01-05-0000-add-rss-title.js
Normal file
11
db/knex_migrations/2026-01-05-0000-add-rss-title.js
Normal 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");
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user