Merge pull request #4066 from matrix-org/t3chguy/piwik_csp

Use embedded piwik script rather than piwik.js to respect CSP
This commit is contained in:
Michael Telatynski 2020-02-13 10:59:02 +00:00 committed by GitHub
commit 12c743b160
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 142 additions and 85 deletions

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,6 +15,8 @@
limitations under the License. limitations under the License.
*/ */
import React from 'react';
import { getCurrentLanguage, _t, _td } from './languageHandler'; import { getCurrentLanguage, _t, _td } from './languageHandler';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
@ -106,61 +109,80 @@ function whitelistRedact(whitelist, str) {
return '<redacted>'; return '<redacted>';
} }
const UID_KEY = "mx_Riot_Analytics_uid";
const CREATION_TS_KEY = "mx_Riot_Analytics_cts";
const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc";
const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
function getUid() {
try {
let data = localStorage.getItem(UID_KEY);
if (!data) {
localStorage.setItem(UID_KEY, data = [...Array(16)].map(() => Math.random().toString(16)[2]).join(''));
}
return data;
} catch (e) {
console.error("Analytics error: ", e);
return "";
}
}
const HEARTBEAT_INTERVAL = 30 * 1000; // seconds
class Analytics { class Analytics {
constructor() { constructor() {
this._paq = null; this.baseUrl = null;
this.disabled = true; this.siteId = null;
this.visitVariables = {};
this.firstPage = true; this.firstPage = true;
this._heartbeatIntervalID = null;
this.creationTs = localStorage.getItem(CREATION_TS_KEY);
if (!this.creationTs) {
localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime());
}
this.lastVisitTs = localStorage.getItem(LAST_VISIT_TS_KEY);
this.visitCount = localStorage.getItem(VISIT_COUNT_KEY) || 0;
localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
}
get disabled() {
return !this.baseUrl;
} }
/** /**
* Enable Analytics if initialized but disabled * Enable Analytics if initialized but disabled
* otherwise try and initalize, no-op if piwik config missing * otherwise try and initalize, no-op if piwik config missing
*/ */
enable() { async enable() {
if (this._paq || this._init()) { if (!this.disabled) return;
this.disabled = false;
}
}
/**
* Disable Analytics calls, will not fully unload Piwik until a refresh,
* but this is second best, Piwik should not pull anything implicitly.
*/
disable() {
this.trackEvent('Analytics', 'opt-out');
// disableHeartBeatTimer is undocumented but exists in the piwik code
// the _paq.push method will result in an error being printed in the console
// if an unknown method signature is passed
this._paq.push(['disableHeartBeatTimer']);
this.disabled = true;
}
_init() {
const config = SdkConfig.get(); const config = SdkConfig.get();
if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return; if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return;
const url = config.piwik.url; this.baseUrl = new URL("piwik.php", config.piwik.url);
const siteId = config.piwik.siteId; // set constants
const self = this; this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking
this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking
window._paq = this._paq = window._paq || []; this.baseUrl.searchParams.set("apiv", 1); // API version to use
this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF
this._paq.push(['setTrackerUrl', url+'piwik.php']); // set user parameters
this._paq.push(['setSiteId', siteId]); this.baseUrl.searchParams.set("_id", getUid()); // uuid
this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts
this._paq.push(['trackAllContentImpressions']); this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count
this._paq.push(['discardHashTag', false]); if (this.lastVisitTs) {
this._paq.push(['enableHeartBeatTimer']); this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts
// this._paq.push(['enableLinkTracking', true]); }
const platform = PlatformPeg.get(); const platform = PlatformPeg.get();
this._setVisitVariable('App Platform', platform.getHumanReadableName()); this._setVisitVariable('App Platform', platform.getHumanReadableName());
platform.getAppVersion().then((version) => { try {
this._setVisitVariable('App Version', version); this._setVisitVariable('App Version', await platform.getAppVersion());
}).catch(() => { } catch (e) {
this._setVisitVariable('App Version', 'unknown'); this._setVisitVariable('App Version', 'unknown');
}); }
this._setVisitVariable('Chosen Language', getCurrentLanguage()); this._setVisitVariable('Chosen Language', getCurrentLanguage());
@ -168,20 +190,64 @@ class Analytics {
this._setVisitVariable('Instance', window.location.pathname); this._setVisitVariable('Instance', window.location.pathname);
} }
(function() { // start heartbeat
const g = document.createElement('script'); this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
const s = document.getElementsByTagName('script')[0]; }
g.type='text/javascript'; g.async=true; g.defer=true; g.src=url+'piwik.js';
g.onload = function() { /**
console.log('Initialised anonymous analytics'); * Disable Analytics, stop the heartbeat and clear identifiers from localStorage
self._paq = window._paq; */
disable() {
if (this.disabled) return;
this.trackEvent('Analytics', 'opt-out');
window.clearInterval(this._heartbeatIntervalID);
this.baseUrl = null;
this.visitVariables = {};
localStorage.removeItem(UID_KEY);
localStorage.removeItem(CREATION_TS_KEY);
localStorage.removeItem(VISIT_COUNT_KEY);
localStorage.removeItem(LAST_VISIT_TS_KEY);
}
async _track(data) {
if (this.disabled) return;
const now = new Date();
const params = {
...data,
url: getRedactedUrl(),
_cvar: this.visitVariables, // user custom vars
res: `${window.screen.width}x${window.screen.height}`, // resolution as WWWWxHHHH
rand: String(Math.random()).slice(2, 8), // random nonce to cache-bust
h: now.getHours(),
m: now.getMinutes(),
s: now.getSeconds(),
}; };
s.parentNode.insertBefore(g, s); const url = new URL(this.baseUrl);
})(); for (const key in params) {
url.searchParams.set(key, params[key]);
}
return true; try {
await window.fetch(url, {
method: "GET",
mode: "no-cors",
cache: "no-cache",
redirect: "follow",
});
} catch (e) {
console.error("Analytics error: ", e);
window.err = e;
}
}
ping() {
this._track({
ping: 1,
});
localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts
} }
trackPageChange(generationTimeMs) { trackPageChange(generationTimeMs) {
@ -193,31 +259,29 @@ class Analytics {
return; return;
} }
if (typeof generationTimeMs === 'number') { if (typeof generationTimeMs !== 'number') {
this._paq.push(['setGenerationTimeMs', generationTimeMs]);
} else {
console.warn('Analytics.trackPageChange: expected generationTimeMs to be a number'); console.warn('Analytics.trackPageChange: expected generationTimeMs to be a number');
// But continue anyway because we still want to track the change // But continue anyway because we still want to track the change
} }
this._paq.push(['setCustomUrl', getRedactedUrl()]); this._track({
this._paq.push(['trackPageView']); gt_ms: generationTimeMs,
});
} }
trackEvent(category, action, name, value) { trackEvent(category, action, name, value) {
if (this.disabled) return; if (this.disabled) return;
this._paq.push(['setCustomUrl', getRedactedUrl()]); this._track({
this._paq.push(['trackEvent', category, action, name, value]); e_c: category,
} e_a: action,
e_n: name,
logout() { e_v: value,
if (this.disabled) return; });
this._paq.push(['deleteCookies']);
} }
_setVisitVariable(key, value) { _setVisitVariable(key, value) {
if (this.disabled) return; if (this.disabled) return;
this._paq.push(['setCustomVariable', customVariables[key].id, key, value, 'visit']); this.visitVariables[customVariables[key].id] = [key, value];
} }
setLoggedIn(isGuest, homeserverUrl, identityServerUrl) { setLoggedIn(isGuest, homeserverUrl, identityServerUrl) {
@ -234,23 +298,16 @@ class Analytics {
this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl)); this._setVisitVariable('Identity Server URL', whitelistRedact(whitelistedISUrls, identityServerUrl));
} }
setRichtextMode(state) {
if (this.disabled) return;
this._setVisitVariable('RTE: Uses Richtext Mode', state ? 'on' : 'off');
}
setBreadcrumbs(state) { setBreadcrumbs(state) {
if (this.disabled) return; if (this.disabled) return;
this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled'); this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
} }
showDetailsModal() { showDetailsModal = () => {
let rows = []; let rows = [];
if (window.Piwik) { if (!this.disabled) {
const Tracker = window.Piwik.getAsyncTracker(); rows = Object.values(this.visitVariables);
rows = Object.values(customVariables).map((v) => Tracker.getCustomVariable(v.id)).filter(Boolean);
} else { } else {
// Piwik may not have been enabled, so show example values
rows = Object.keys(customVariables).map( rows = Object.keys(customVariables).map(
(k) => [ (k) => [
k, k,
@ -300,7 +357,7 @@ class Analytics {
</div> </div>
</div>, </div>,
}); });
} };
} }
if (!global.mxAnalytics) { if (!global.mxAnalytics) {

View file

@ -632,7 +632,7 @@ export async function onLoggedOut() {
* @returns {Promise} promise which resolves once the stores have been cleared * @returns {Promise} promise which resolves once the stores have been cleared
*/ */
async function _clearStorage() { async function _clearStorage() {
Analytics.logout(); Analytics.disable();
if (window.localStorage) { if (window.localStorage) {
window.localStorage.clear(); window.localStorage.clear();