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,18 +1,21 @@
/* /*
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.
You may obtain a copy of the License at You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0 http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
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';
@ -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();