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");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { getCurrentLanguage, _t, _td } from './languageHandler';
import PlatformPeg from './PlatformPeg';
@ -106,61 +109,80 @@ function whitelistRedact(whitelist, str) {
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 {
constructor() {
this._paq = null;
this.disabled = true;
this.baseUrl = null;
this.siteId = null;
this.visitVariables = {};
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
* otherwise try and initalize, no-op if piwik config missing
*/
enable() {
if (this._paq || this._init()) {
this.disabled = false;
}
}
async enable() {
if (!this.disabled) return;
/**
* 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();
if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return;
const url = config.piwik.url;
const siteId = config.piwik.siteId;
const self = this;
window._paq = this._paq = window._paq || [];
this._paq.push(['setTrackerUrl', url+'piwik.php']);
this._paq.push(['setSiteId', siteId]);
this._paq.push(['trackAllContentImpressions']);
this._paq.push(['discardHashTag', false]);
this._paq.push(['enableHeartBeatTimer']);
// this._paq.push(['enableLinkTracking', true]);
this.baseUrl = new URL("piwik.php", config.piwik.url);
// set constants
this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking
this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking
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
// set user parameters
this.baseUrl.searchParams.set("_id", getUid()); // uuid
this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts
this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count
if (this.lastVisitTs) {
this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts
}
const platform = PlatformPeg.get();
this._setVisitVariable('App Platform', platform.getHumanReadableName());
platform.getAppVersion().then((version) => {
this._setVisitVariable('App Version', version);
}).catch(() => {
try {
this._setVisitVariable('App Version', await platform.getAppVersion());
} catch (e) {
this._setVisitVariable('App Version', 'unknown');
});
}
this._setVisitVariable('Chosen Language', getCurrentLanguage());
@ -168,20 +190,64 @@ class Analytics {
this._setVisitVariable('Instance', window.location.pathname);
}
(function() {
const g = document.createElement('script');
const s = document.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.defer=true; g.src=url+'piwik.js';
// start heartbeat
this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
}
g.onload = function() {
console.log('Initialised anonymous analytics');
self._paq = window._paq;
/**
* Disable Analytics, stop the heartbeat and clear identifiers from localStorage
*/
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) {
@ -193,31 +259,29 @@ class Analytics {
return;
}
if (typeof generationTimeMs === 'number') {
this._paq.push(['setGenerationTimeMs', generationTimeMs]);
} else {
if (typeof generationTimeMs !== 'number') {
console.warn('Analytics.trackPageChange: expected generationTimeMs to be a number');
// But continue anyway because we still want to track the change
}
this._paq.push(['setCustomUrl', getRedactedUrl()]);
this._paq.push(['trackPageView']);
this._track({
gt_ms: generationTimeMs,
});
}
trackEvent(category, action, name, value) {
if (this.disabled) return;
this._paq.push(['setCustomUrl', getRedactedUrl()]);
this._paq.push(['trackEvent', category, action, name, value]);
}
logout() {
if (this.disabled) return;
this._paq.push(['deleteCookies']);
this._track({
e_c: category,
e_a: action,
e_n: name,
e_v: value,
});
}
_setVisitVariable(key, value) {
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) {
@ -234,23 +298,16 @@ class Analytics {
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) {
if (this.disabled) return;
this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
}
showDetailsModal() {
showDetailsModal = () => {
let rows = [];
if (window.Piwik) {
const Tracker = window.Piwik.getAsyncTracker();
rows = Object.values(customVariables).map((v) => Tracker.getCustomVariable(v.id)).filter(Boolean);
if (!this.disabled) {
rows = Object.values(this.visitVariables);
} else {
// Piwik may not have been enabled, so show example values
rows = Object.keys(customVariables).map(
(k) => [
k,
@ -300,7 +357,7 @@ class Analytics {
</div>
</div>,
});
}
};
}
if (!global.mxAnalytics) {

View file

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