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:
commit
12c743b160
2 changed files with 142 additions and 85 deletions
221
src/Analytics.js
221
src/Analytics.js
|
@ -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) {
|
||||||
|
|
|
@ -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();
|
||||||
|
|
Loading…
Reference in a new issue