diff --git a/db/knex_init_db.js b/db/knex_init_db.js index 46bff4bfa..de9997dd4 100644 --- a/db/knex_init_db.js +++ b/db/knex_init_db.js @@ -202,7 +202,10 @@ async function createTables() { table.text("footer_text"); table.text("custom_css"); table.boolean("show_powered_by").notNullable().defaultTo(true); - table.string("google_analytics_tag_id"); + table.string("analytics_id"); + table.string("analytics_domain_url"); + table.enu("analytics_type", [ "google", "umami", "plausible" ]).defaultTo(null); + }); // maintenance_status_page diff --git a/db/knex_migrations/2025-02-17-2142-generalize-analytics.js b/db/knex_migrations/2025-02-17-2142-generalize-analytics.js new file mode 100644 index 000000000..e4924feaf --- /dev/null +++ b/db/knex_migrations/2025-02-17-2142-generalize-analytics.js @@ -0,0 +1,23 @@ +// Udpate status_page table to generalize analytics fields +exports.up = function (knex) { + return knex.schema + .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", "matomo" ]).defaultTo(null); + + }).then(() => { + // After a succesful migration, add google as default for previous pages + knex("status_page").whereNotNull("analytics_id").update({ + "analytics_type": "google", + }); + }); +}; + +exports.down = function (knex) { + return knex.schema.alterTable("status_page", function (table) { + table.renameColumn("analytics_id", "google_analytics_tag_id"); + table.dropColumn("analytics_domain_url"); + table.dropColumn("analytics_type"); + }); +}; diff --git a/server/analytics/analytics.js b/server/analytics/analytics.js new file mode 100644 index 000000000..79e570b9b --- /dev/null +++ b/server/analytics/analytics.js @@ -0,0 +1,48 @@ +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 + * into a webpage. + * @param {typeof import("../model/status_page").StatusPage} statusPage Status page populate HTML with + * @returns {string} HTML script tags to inject into page + */ +function getAnalyticsScript(statusPage) { + switch (statusPage.analyticsType) { + case "google": + return googleAnalytics.getGoogleAnalyticsScript(statusPage.analyticsId); + case "umami": + 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; + } +} + +/** + * Function that checks wether the selected analytics has been configured properly + * @param {typeof import("../model/status_page").StatusPage} statusPage Status page populate HTML with + * @returns {boolean} Boolean defining if the analytics config is valid + */ +function isValidAnalyticsConfig(statusPage) { + switch (statusPage.analyticsType) { + case "google": + return statusPage.analyticsId != null; + case "umami": + case "plausible": + case "matomo": + return statusPage.analyticsId != null && statusPage.analyticsDomainUrl != null; + default: + return false; + } +} + +module.exports = { + getAnalyticsScript, + isValidAnalyticsConfig +}; diff --git a/server/google-analytics.js b/server/analytics/google-analytics.js similarity index 100% rename from server/google-analytics.js rename to server/analytics/google-analytics.js 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 ` + <script type="text/javascript"> + var _paq = window._paq = window._paq || []; + _paq.push(['trackPageView']); + _paq.push(['enableLinkTracking']); + (function() { + var u="//${escapedMatomoUrlHTMLAttribute}/"; + _paq.push(['setTrackerUrl', u+'matomo.php']); + _paq.push(['setSiteId', ${escapedSiteIdHTMLAttribute}]); + var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0]; + g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s); + })(); + </script> + `; +} + +module.exports = { + getMatomoAnalyticsScript, +}; diff --git a/server/analytics/plausible-analytics.js b/server/analytics/plausible-analytics.js new file mode 100644 index 000000000..14df98115 --- /dev/null +++ b/server/analytics/plausible-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 Plausible Analytics script + * into a webpage. + * @param {string} plausibleDomainUrl Domain name with tld to use with the Plausible Analytics script. + * @param {string} domainsToMonitor Domains to track seperated by a ',' to add Plausible Analytics script. + * @returns {string} HTML script tags to inject into page + */ +function getPlausibleAnalyticsScript(plausibleDomainUrl, domainsToMonitor) { + let escapedDomainUrlJS = jsesc(plausibleDomainUrl, { isScriptContext: true }); + let escapedWebsiteIdJS = jsesc(domainsToMonitor, { isScriptContext: true }); + + if (escapedDomainUrlJS) { + escapedDomainUrlJS = escapedDomainUrlJS.trim(); + } + + if (escapedWebsiteIdJS) { + escapedWebsiteIdJS = escapedWebsiteIdJS.trim(); + } + + // Escape the domain url for use in an HTML attribute. + let escapedDomainUrlHTMLAttribute = escape(escapedDomainUrlJS); + + // Escape the website id for use in an HTML attribute. + let escapedWebsiteIdHTMLAttribute = escape(escapedWebsiteIdJS); + + return ` + <script defer src="https://${escapedDomainUrlHTMLAttribute}/js/script.js" data-domain="${escapedWebsiteIdHTMLAttribute}"></script> + `; +} + +module.exports = { + getPlausibleAnalyticsScript +}; diff --git a/server/analytics/umami-analytics.js b/server/analytics/umami-analytics.js new file mode 100644 index 000000000..5a4a172f9 --- /dev/null +++ b/server/analytics/umami-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 Umami Analytics script + * into a webpage. + * @param {string} domainUrl Domain name with tld to use with the Umami Analytics script. + * @param {string} websiteId Website ID to use with the Umami Analytics script. + * @returns {string} HTML script tags to inject into page + */ +function getUmamiAnalyticsScript(domainUrl, websiteId) { + let escapedDomainUrlJS = jsesc(domainUrl, { isScriptContext: true }); + let escapedWebsiteIdJS = jsesc(websiteId, { isScriptContext: true }); + + if (escapedDomainUrlJS) { + escapedDomainUrlJS = escapedDomainUrlJS.trim(); + } + + if (escapedWebsiteIdJS) { + escapedWebsiteIdJS = escapedWebsiteIdJS.trim(); + } + + // Escape the domain url for use in an HTML attribute. + let escapedDomainUrlHTMLAttribute = escape(escapedDomainUrlJS); + + // Escape the website id for use in an HTML attribute. + let escapedWebsiteIdHTMLAttribute = escape(escapedWebsiteIdJS); + + return ` + <script defer src="https://${escapedDomainUrlHTMLAttribute}/script.js" data-website-id="${escapedWebsiteIdHTMLAttribute}"></script> + `; +} + +module.exports = { + getUmamiAnalyticsScript, +}; diff --git a/server/model/status_page.js b/server/model/status_page.js index 38f548ebb..32188ffaa 100644 --- a/server/model/status_page.js +++ b/server/model/status_page.js @@ -3,7 +3,7 @@ const { R } = require("redbean-node"); const cheerio = require("cheerio"); const { UptimeKumaServer } = require("../uptime-kuma-server"); const jsesc = require("jsesc"); -const googleAnalytics = require("../google-analytics"); +const analytics = require("../analytics/analytics"); const { marked } = require("marked"); const { Feed } = require("feed"); const config = require("../config"); @@ -120,9 +120,9 @@ class StatusPage extends BeanModel { const head = $("head"); - if (statusPage.googleAnalyticsTagId) { - let escapedGoogleAnalyticsScript = googleAnalytics.getGoogleAnalyticsScript(statusPage.googleAnalyticsTagId); - head.append($(escapedGoogleAnalyticsScript)); + if (analytics.isValidAnalyticsConfig(statusPage)) { + let escapedAnalyticsScript = analytics.getAnalyticsScript(statusPage); + head.append($(escapedAnalyticsScript)); } // OG Meta Tags @@ -407,7 +407,9 @@ class StatusPage extends BeanModel { customCSS: this.custom_css, footerText: this.footer_text, showPoweredBy: !!this.show_powered_by, - googleAnalyticsId: this.google_analytics_tag_id, + analyticsId: this.analytics_id, + analyticsDomainUrl: this.analytics_domain_url, + analyticsType: this.analytics_type, showCertificateExpiry: !!this.show_certificate_expiry, }; } @@ -430,7 +432,9 @@ class StatusPage extends BeanModel { customCSS: this.custom_css, footerText: this.footer_text, showPoweredBy: !!this.show_powered_by, - googleAnalyticsId: this.google_analytics_tag_id, + analyticsId: this.analytics_id, + analyticsDomainUrl: this.analytics_domain_url, + analyticsType: this.analytics_type, showCertificateExpiry: !!this.show_certificate_expiry, }; } diff --git a/server/socket-handlers/status-page-socket-handler.js b/server/socket-handlers/status-page-socket-handler.js index 1114d81fd..60957d49d 100644 --- a/server/socket-handlers/status-page-socket-handler.js +++ b/server/socket-handlers/status-page-socket-handler.js @@ -166,7 +166,9 @@ module.exports.statusPageSocketHandler = (socket) => { statusPage.show_powered_by = config.showPoweredBy; statusPage.show_certificate_expiry = config.showCertificateExpiry; statusPage.modified_date = R.isoDateTime(); - statusPage.google_analytics_tag_id = config.googleAnalyticsId; + statusPage.analytics_id = config.analyticsId; + statusPage.analytics_domain_url = config.analyticsDomainUrl; + statusPage.analytics_type = config.analyticsType; await R.store(statusPage); diff --git a/src/lang/en.json b/src/lang/en.json index cb704b0fe..94deb0a81 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -787,6 +787,11 @@ "wayToGetClickSendSMSToken": "You can get API Username and API Key from {0} .", "Custom Monitor Type": "Custom Monitor Type", "Google Analytics ID": "Google Analytics ID", + "Analytics Type": "Analytics Type", + "Analytics ID": "Analytics ID", + "Analytics Domain URL": "Analytics Domain URL", + "Umami Analytics Domain Url": "Umami Analytics Domain Url", + "Umami Analytics Website ID": "Umami Analytics Website ID", "Edit Tag": "Edit Tag", "Server Address": "Server Address", "Learn More": "Learn More", diff --git a/src/pages/StatusPage.vue b/src/pages/StatusPage.vue index 116968282..8a35fef60 100644 --- a/src/pages/StatusPage.vue +++ b/src/pages/StatusPage.vue @@ -1,3 +1,29 @@ +<script setup> +// Analytics options +const analyticsOptions = [ + { + name: "None", + value: null + }, + { + name: "Google", + value: "google" + }, + { + name: "Umami", + value: "umami" + }, + { + name: "Plausible", + value: "plausible" + }, + { + name: "Matomo", + value: "matomo" + } +]; +</script> + <template> <div v-if="loadedTheme" class="container mt-3"> <!-- Sidebar for edit mode --> @@ -92,10 +118,25 @@ </ul> </div> - <!-- Google Analytics --> + <!-- Analytics --> + <div class="my-3"> - <label for="googleAnalyticsTag" class="form-label">{{ $t("Google Analytics ID") }}</label> - <input id="googleAnalyticsTag" v-model="config.googleAnalyticsId" type="text" class="form-control" data-testid="google-analytics-input"> + <label for="analyticsType" class="form-label">{{ $t("Analytics Type") }}</label> + <select id="analyticsType" v-model="config.analyticsType" class="form-select" data-testid="analytics-type-select"> + <option v-for="(analyticOption, index) in analyticsOptions" :key="index" :value="analyticOption.value"> + {{ analyticOption.name }} + </option> + </select> + </div> + + <div v-if="config.analyticsType !== null && config.analyticsType !== undefined" class="my-3"> + <label for="analyticsId" class="form-label">{{ $t("Analytics ID") }}</label> + <input id="analyticsId" v-model="config.analyticsId" type="text" class="form-control" data-testid="analytics-id-input"> + </div> + + <div v-if="config.analyticsType !== null && config.analyticsType !== undefined && config.analyticsType !== 'google'" class="my-3"> + <label for="analyticsDomainUrl" class="form-label">{{ $t("Analytics Domain URL") }}</label> + <input id="analyticsDomainUrl" v-model="config.analyticsDomainUrl" type="text" class="form-control" data-testid="analytics-domain-url-input"> </div> <!-- Custom CSS --> diff --git a/test/e2e/specs/status-page.spec.js b/test/e2e/specs/status-page.spec.js index f525dfc6f..e75f3c993 100644 --- a/test/e2e/specs/status-page.spec.js +++ b/test/e2e/specs/status-page.spec.js @@ -18,6 +18,12 @@ test.describe("Status Page", () => { const refreshInterval = 30; const theme = "dark"; const googleAnalyticsId = "G-123"; + const umamiAnalyticsDomainUrl = "example.com"; + 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"; @@ -57,7 +63,8 @@ test.describe("Status Page", () => { await page.getByTestId("show-tags-checkbox").uncheck(); await page.getByTestId("show-powered-by-checkbox").uncheck(); await page.getByTestId("show-certificate-expiry-checkbox").uncheck(); - await page.getByTestId("google-analytics-input").fill(googleAnalyticsId); + await page.getByTestId("analytics-type-select").selectOption("google"); + await page.getByTestId("analytics-id-input").fill(googleAnalyticsId); await page.getByTestId("custom-css-input").getByTestId("textarea").fill(customCss); // Prism await expect(page.getByTestId("description-editable")).toHaveText(descriptionText); await expect(page.getByTestId("custom-footer-editable")).toHaveText(footerText); @@ -100,12 +107,12 @@ test.describe("Status Page", () => { expect(updateCountdown).toBeLessThanOrEqual(refreshInterval + 10); await expect(page.locator("body")).toHaveClass(theme); - expect(await page.locator("head").innerHTML()).toContain(googleAnalyticsId); const backgroundColor = await page.evaluate(() => window.getComputedStyle(document.body).backgroundColor); expect(backgroundColor).toEqual("rgb(0, 128, 128)"); await screenshot(testInfo, page); + expect(await page.locator("head").innerHTML()).toContain(googleAnalyticsId); // Flip the "Show Tags" and "Show Powered By" switches: await page.getByTestId("edit-button").click(); @@ -114,6 +121,11 @@ test.describe("Status Page", () => { await page.getByTestId("show-powered-by-checkbox").setChecked(true); await screenshot(testInfo, page); + + // Fill in umami analytics after editing + await page.getByTestId("analytics-type-select").selectOption("umami"); + await page.getByTestId("analytics-domain-url-input").fill(umamiAnalyticsDomainUrl); + await page.getByTestId("analytics-id-input").fill(umamiAnalyticsWebsiteId); await page.getByTestId("save-button").click(); await expect(page.getByTestId("edit-sidebar")).toHaveCount(0); @@ -121,6 +133,29 @@ test.describe("Status Page", () => { await expect(page.getByTestId("monitor-tag")).toContainText(tagValue); await screenshot(testInfo, page); + + expect(await page.locator("head").innerHTML()).toContain(umamiAnalyticsDomainUrl); + expect(await page.locator("head").innerHTML()).toContain(umamiAnalyticsWebsiteId); + + await page.getByTestId("edit-button").click(); + // Fill in plausible analytics after editing + await page.getByTestId("analytics-type-select").selectOption("plausible"); + await page.getByTestId("analytics-domain-url-input").fill(plausibleAnalyticsDomainUrl); + await page.getByTestId("analytics-id-input").fill(plausibleAnalyticsDomainsUrls); + await page.getByTestId("save-button").click(); + 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