From a57aabf8c3607b4325a15a72906571b3d92043e8 Mon Sep 17 00:00:00 2001 From: Daniel Oberlechner Date: Sat, 20 Jun 2026 01:07:35 +0200 Subject: [PATCH] feat: add tracking support on status pages for rybbit analytics (#7525) --- ...26-06-19-1411-add-rybbit-analytics-type.js | 33 +++++++++++++++++ server/analytics/analytics.js | 4 +++ server/analytics/rybbit-analytics.js | 36 +++++++++++++++++++ .../status-page-socket-handler.js | 2 +- src/lang/en.json | 1 + src/pages/StatusPage.vue | 1 + 6 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 db/knex_migrations/2026-06-19-1411-add-rybbit-analytics-type.js create mode 100644 server/analytics/rybbit-analytics.js diff --git a/db/knex_migrations/2026-06-19-1411-add-rybbit-analytics-type.js b/db/knex_migrations/2026-06-19-1411-add-rybbit-analytics-type.js new file mode 100644 index 000000000..345a0fc55 --- /dev/null +++ b/db/knex_migrations/2026-06-19-1411-add-rybbit-analytics-type.js @@ -0,0 +1,33 @@ +const newValues = ["google", "umami", "plausible", "matomo", "rybbit"]; +const oldValues = ["google", "umami", "plausible", "matomo"]; + +/** + * Rebuild the status_page.analytics_type enum with the given values, keeping existing data. + * The column is dropped and re-created because SQLite's .enu().alter() does not replace the old CHECK constraint. + * @param {import("knex").Knex} knex The knex instance + * @param {string[]} allowedValues Allowed analytics_type values + * @returns {Promise} + */ +async function rebuildAnalyticsType(knex, allowedValues) { + const rows = await knex("status_page").whereNotNull("analytics_type").select("id", "analytics_type"); + + await knex.schema.alterTable("status_page", (table) => { + table.dropColumn("analytics_type"); + }); + await knex.schema.alterTable("status_page", (table) => { + table.enu("analytics_type", allowedValues).defaultTo(null); + }); + + for (const row of rows) { + await knex("status_page").where("id", row.id).update({ analytics_type: row.analytics_type }); + } +} + +exports.up = function (knex) { + return rebuildAnalyticsType(knex, newValues); +}; + +exports.down = async function (knex) { + await knex("status_page").where("analytics_type", "rybbit").update({ analytics_type: null }); + await rebuildAnalyticsType(knex, oldValues); +}; diff --git a/server/analytics/analytics.js b/server/analytics/analytics.js index c088a8e5e..3b3cb4841 100644 --- a/server/analytics/analytics.js +++ b/server/analytics/analytics.js @@ -2,6 +2,7 @@ const googleAnalytics = require("./google-analytics"); const umamiAnalytics = require("./umami-analytics"); const plausibleAnalytics = require("./plausible-analytics"); const matomoAnalytics = require("./matomo-analytics"); +const rybbitAnalytics = require("./rybbit-analytics"); /** * Returns a string that represents the javascript that is required to insert the selected Analytics' script @@ -22,6 +23,8 @@ function getAnalyticsScript(statusPage) { ); case "matomo": return matomoAnalytics.getMatomoAnalyticsScript(statusPage.analyticsScriptUrl, statusPage.analyticsId); + case "rybbit": + return rybbitAnalytics.getRybbitAnalyticsScript(statusPage.analyticsScriptUrl, statusPage.analyticsId); default: return null; } @@ -39,6 +42,7 @@ function isValidAnalyticsConfig(statusPage) { case "umami": case "plausible": case "matomo": + case "rybbit": return statusPage.analyticsId != null && statusPage.analyticsScriptUrl != null; default: return false; diff --git a/server/analytics/rybbit-analytics.js b/server/analytics/rybbit-analytics.js new file mode 100644 index 000000000..06d70ecf7 --- /dev/null +++ b/server/analytics/rybbit-analytics.js @@ -0,0 +1,36 @@ +const jsesc = require("jsesc"); +const { escape } = require("html-escaper"); + +/** + * Returns a string that represents the javascript that is required to insert the Rybbit Analytics script + * into a webpage. + * @param {string} scriptUrl the Rybbit Analytics script url. + * @param {string} siteId Site ID to use with the Rybbit Analytics script. + * @returns {string} HTML script tags to inject into page + */ +function getRybbitAnalyticsScript(scriptUrl, siteId) { + let escapedScriptUrlJS = jsesc(scriptUrl, { isScriptContext: true }); + let escapedSiteIdJS = jsesc(siteId, { isScriptContext: true }); + + if (escapedScriptUrlJS) { + escapedScriptUrlJS = escapedScriptUrlJS.trim(); + } + + if (escapedSiteIdJS) { + escapedSiteIdJS = escapedSiteIdJS.trim(); + } + + // Escape the Script url for use in an HTML attribute. + let escapedScriptUrlHTMLAttribute = escape(escapedScriptUrlJS); + + // Escape the site id for use in an HTML attribute. + let escapedSiteIdHTMLAttribute = escape(escapedSiteIdJS); + + return ` + + `; +} + +module.exports = { + getRybbitAnalyticsScript, +}; diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js index 7d33f0851..9a1ce2b79 100644 --- a/server/socket-handlers/status-page-socket-handler.js +++ b/server/socket-handlers/status-page-socket-handler.js @@ -339,7 +339,7 @@ module.exports.statusPageSocketHandler = (socket) => { statusPage.modified_date = R.isoDateTime(); statusPage.analytics_id = config.analyticsId; statusPage.analytics_script_url = config.analyticsScriptUrl; - const validAnalyticsTypes = ["google", "umami", "plausible", "matomo"]; + const validAnalyticsTypes = ["google", "umami", "plausible", "matomo", "rybbit"]; if (config.analyticsType !== null && !validAnalyticsTypes.includes(config.analyticsType)) { throw new Error("Invalid analytics type"); } diff --git a/src/lang/en.json b/src/lang/en.json index c1f07fb81..6efabeec6 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -1383,6 +1383,7 @@ "Plausible": "Plausible", "Matomo": "Matomo", "Umami": "Umami", + "Rybbit": "Rybbit", "Disable URL in Notification": "Disable URL in Notification", "Suppress Notifications": "Suppress Notifications", "discordSuppressNotificationsHelptext": "When enabled, messages will be posted to the channel but won't trigger push or desktop notifications for recipients.", diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index 4003b4a06..3757d91f5 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -161,6 +161,7 @@ +