From a1e63390ab974c48a2cf6592a60c22a773248868 Mon Sep 17 00:00:00 2001 From: hadestructhor <60148800+hadestructhor@users.noreply.github.com> Date: Fri, 21 Feb 2025 23:26:44 +0100 Subject: [PATCH] feat: add Matomo analytics support --- .../2025-02-17-2142-generalize-analytics.js | 2 +- server/analytics/analytics.js | 4 ++ server/analytics/matomo-analytics.js | 47 +++++++++++++++++++ src/pages/StatusPage.vue | 4 ++ test/e2e/specs/status-page.spec.js | 12 +++++ 5 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 server/analytics/matomo-analytics.js diff --git a/db/knex_migrations/2025-02-17-2142-generalize-analytics.js b/db/knex_migrations/2025-02-17-2142-generalize-analytics.js index 34a52224f..e4924feaf 100644 --- a/db/knex_migrations/2025-02-17-2142-generalize-analytics.js +++ b/db/knex_migrations/2025-02-17-2142-generalize-analytics.js @@ -4,7 +4,7 @@ exports.up = function (knex) { .alterTable("status_page", function (table) { table.renameColumn("google_analytics_tag_id", "analytics_id"); table.string("analytics_domain_url"); - table.enu("analytics_type", [ "google", "umami", "plausible" ]).defaultTo(null); + table.enu("analytics_type", [ "google", "umami", "plausible", "matomo" ]).defaultTo(null); }).then(() => { // After a succesful migration, add google as default for previous pages diff --git a/server/analytics/analytics.js b/server/analytics/analytics.js index a5e2c1120..79e570b9b 100644 --- a/server/analytics/analytics.js +++ b/server/analytics/analytics.js @@ -1,6 +1,7 @@ const googleAnalytics = require("./google-analytics"); const umamiAnalytics = require("./umami-analytics"); const plausibleAnalytics = require("./plausible-analytics"); +const matomoAnalytics = require("./matomo-analytics"); /** * Returns a string that represents the javascript that is required to insert the selected Analytics' script @@ -16,6 +17,8 @@ function getAnalyticsScript(statusPage) { return umamiAnalytics.getUmamiAnalyticsScript(statusPage.analyticsDomainUrl, statusPage.analyticsId); case "plausible": return plausibleAnalytics.getPlausibleAnalyticsScript(statusPage.analyticsDomainUrl, statusPage.analyticsId); + case "matomo": + return matomoAnalytics.getMatomoAnalyticsScript(statusPage.analyticsDomainUrl, statusPage.analyticsId); default: return null; } @@ -32,6 +35,7 @@ function isValidAnalyticsConfig(statusPage) { return statusPage.analyticsId != null; case "umami": case "plausible": + case "matomo": return statusPage.analyticsId != null && statusPage.analyticsDomainUrl != null; default: return false; diff --git a/server/analytics/matomo-analytics.js b/server/analytics/matomo-analytics.js new file mode 100644 index 000000000..fdc009e63 --- /dev/null +++ b/server/analytics/matomo-analytics.js @@ -0,0 +1,47 @@ +const jsesc = require("jsesc"); +const { escape } = require("html-escaper"); + +/** + * Returns a string that represents the javascript that is required to insert the Matomo Analytics script + * into a webpage. + * @param {string} matomoUrl Domain name with tld to use with the Matomo Analytics script. + * @param {string} siteId Site ID to use with the Matomo Analytics script. + * @returns {string} HTML script tags to inject into page + */ +function getMatomoAnalyticsScript(matomoUrl, siteId) { + let escapedMatomoUrlJS = jsesc(matomoUrl, { isScriptContext: true }); + let escapedSiteIdJS = jsesc(siteId, { isScriptContext: true }); + + if (escapedMatomoUrlJS) { + escapedMatomoUrlJS = escapedMatomoUrlJS.trim(); + } + + if (escapedSiteIdJS) { + escapedSiteIdJS = escapedSiteIdJS.trim(); + } + + // Escape the domain url for use in an HTML attribute. + let escapedMatomoUrlHTMLAttribute = escape(escapedMatomoUrlJS); + + // Escape the website id for use in an HTML attribute. + let escapedSiteIdHTMLAttribute = escape(escapedSiteIdJS); + + return ` + + `; +} + +module.exports = { + getMatomoAnalyticsScript, +}; diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index 983336de3..8a35fef60 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -16,6 +16,10 @@ const analyticsOptions = [ { name: "Plausible", value: "plausible" + }, + { + name: "Matomo", + value: "matomo" } ]; diff --git a/test/e2e/specs/status-page.spec.js b/test/e2e/specs/status-page.spec.js index 43c5d43a6..e75f3c993 100644 --- a/test/e2e/specs/status-page.spec.js +++ b/test/e2e/specs/status-page.spec.js @@ -22,6 +22,8 @@ test.describe("Status Page", () => { const umamiAnalyticsWebsiteId = "606487e2-bc25-45f9-9132-fa8b065aad46"; const plausibleAnalyticsDomainUrl = "example.com"; const plausibleAnalyticsDomainsUrls = "one.com,two.com"; + const matomoUrl = "matomo.com"; + const matomoSiteId = "123456789"; const customCss = "body { background: rgb(0, 128, 128) !important; }"; const descriptionText = "This is an example status page."; const incidentTitle = "Example Outage Incident"; @@ -144,6 +146,16 @@ test.describe("Status Page", () => { await screenshot(testInfo, page); expect(await page.locator("head").innerHTML()).toContain(plausibleAnalyticsDomainUrl); expect(await page.locator("head").innerHTML()).toContain(plausibleAnalyticsDomainsUrls); + + await page.getByTestId("edit-button").click(); + // Fill in matomo analytics after editing + await page.getByTestId("analytics-type-select").selectOption("matomo"); + await page.getByTestId("analytics-domain-url-input").fill(matomoUrl); + await page.getByTestId("analytics-id-input").fill(matomoSiteId); + await page.getByTestId("save-button").click(); + await screenshot(testInfo, page); + expect(await page.locator("head").innerHTML()).toContain(matomoUrl); + expect(await page.locator("head").innerHTML()).toContain(matomoSiteId); }); // @todo Test certificate expiry