diff options
author | Alessio Vanni <vannilla@firemail.cc> | 2019-02-19 21:06:09 +0100 |
---|---|---|
committer | Alessio Vanni <vannilla@firemail.cc> | 2019-02-19 21:06:09 +0100 |
commit | fe2f8acc8210c2ddead4621797b47106a9b38f5b (patch) | |
tree | 5fb103d45d7e4345f56fc068ce8173b82fa7051f /js | |
download | ematrix-fe2f8acc8210c2ddead4621797b47106a9b38f5b.tar.lz ematrix-fe2f8acc8210c2ddead4621797b47106a9b38f5b.tar.xz ematrix-fe2f8acc8210c2ddead4621797b47106a9b38f5b.zip |
Fork uMatrix
Pretty much just changing the name and the copyright.
Diffstat (limited to 'js')
40 files changed, 16853 insertions, 0 deletions
diff --git a/js/about.js b/js/about.js new file mode 100644 index 0000000..acaffa3 --- /dev/null +++ b/js/about.js @@ -0,0 +1,146 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global uDom */ + +'use strict'; + +/******************************************************************************/ + +uDom.onLoad(function() { + +/******************************************************************************/ + +var backupUserDataToFile = function() { + var userDataReady = function(userData) { + vAPI.download({ + 'url': 'data:text/plain,' + encodeURIComponent(JSON.stringify(userData, null, 2)), + 'filename': uDom('[data-i18n="aboutBackupFilename"]').text() + }); + }; + + vAPI.messaging.send('about.js', { what: 'getAllUserData' }, userDataReady); +}; + +/******************************************************************************/ + +function restoreUserDataFromFile() { + var validateBackup = function(s) { + var userData = null; + try { + userData = JSON.parse(s); + } + catch (e) { + userData = null; + } + if ( userData === null ) { + return null; + } + if ( + typeof userData !== 'object' || + typeof userData.version !== 'string' || + typeof userData.when !== 'number' || + typeof userData.settings !== 'object' || + typeof userData.rules !== 'string' || + typeof userData.hostsFiles !== 'object' + ) { + return null; + } + return userData; + }; + + var fileReaderOnLoadHandler = function() { + var userData = validateBackup(this.result); + if ( !userData ) { + window.alert(uDom('[data-i18n="aboutRestoreError"]').text()); + return; + } + var time = new Date(userData.when); + var msg = uDom('[data-i18n="aboutRestoreConfirm"]').text() + .replace('{{time}}', time.toLocaleString()); + var proceed = window.confirm(msg); + if ( proceed ) { + vAPI.messaging.send( + 'about.js', + { what: 'restoreAllUserData', userData: userData } + ); + } + }; + + var file = this.files[0]; + if ( file === undefined || file.name === '' ) { + return; + } + if ( file.type.indexOf('text') !== 0 ) { + return; + } + var fr = new FileReader(); + fr.onload = fileReaderOnLoadHandler; + fr.readAsText(file); +} + +/******************************************************************************/ + +var startRestoreFilePicker = function() { + var input = document.getElementById('restoreFilePicker'); + // Reset to empty string, this will ensure an change event is properly + // triggered if the user pick a file, even if it is the same as the last + // one picked. + input.value = ''; + input.click(); +}; + +/******************************************************************************/ + +var resetUserData = function() { + var proceed = window.confirm(uDom('[data-i18n="aboutResetConfirm"]').text()); + if ( proceed ) { + vAPI.messaging.send('about.js', { what: 'resetAllUserData' }); + } +}; + +/******************************************************************************/ + +(function() { + var renderStats = function(details) { + document.getElementById('aboutVersion').textContent = details.version; + var template = uDom('[data-i18n="aboutStorageUsed"]').text(); + var storageUsed = '?'; + if ( typeof details.storageUsed === 'number' ) { + storageUsed = details.storageUsed.toLocaleString(); + } + document.getElementById('aboutStorageUsed').textContent = + template.replace('{{storageUsed}}', storageUsed); + }; + vAPI.messaging.send('about.js', { what: 'getSomeStats' }, renderStats); +})(); + +/******************************************************************************/ + +uDom('#backupUserDataButton').on('click', backupUserDataToFile); +uDom('#restoreUserDataButton').on('click', startRestoreFilePicker); +uDom('#restoreFilePicker').on('change', restoreUserDataFromFile); +uDom('#resetUserDataButton').on('click', resetUserData); + +/******************************************************************************/ + +}); diff --git a/js/asset-viewer.js b/js/asset-viewer.js new file mode 100644 index 0000000..97a1fb8 --- /dev/null +++ b/js/asset-viewer.js @@ -0,0 +1,46 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +'use strict'; + +/******************************************************************************/ + +(function() { + + var onAssetContentReceived = function(details) { + document.getElementById('content').textContent = + details && (details.content || ''); + }; + + var q = window.location.search; + var matches = q.match(/^\?url=([^&]+)/); + if ( !matches || matches.length !== 2 ) { + return; + } + + vAPI.messaging.send( + 'asset-viewer.js', + { what : 'getAssetContent', url: matches[1] }, + onAssetContentReceived + ); + +})(); diff --git a/js/assets.js b/js/assets.js new file mode 100644 index 0000000..c3fb85f --- /dev/null +++ b/js/assets.js @@ -0,0 +1,911 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2013-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +'use strict'; + +/******************************************************************************/ + +µMatrix.assets = (function() { + +/******************************************************************************/ + +var reIsExternalPath = /^(?:[a-z-]+):\/\//, + errorCantConnectTo = vAPI.i18n('errorCantConnectTo'), + noopfunc = function(){}; + +var api = { +}; + +/******************************************************************************/ + +var observers = []; + +api.addObserver = function(observer) { + if ( observers.indexOf(observer) === -1 ) { + observers.push(observer); + } +}; + +api.removeObserver = function(observer) { + var pos; + while ( (pos = observers.indexOf(observer)) !== -1 ) { + observers.splice(pos, 1); + } +}; + +var fireNotification = function(topic, details) { + var result; + for ( var i = 0; i < observers.length; i++ ) { + if ( observers[i](topic, details) === false ) { + result = false; + } + } + return result; +}; + +/******************************************************************************/ + +api.fetchText = function(url, onLoad, onError) { + var actualUrl = reIsExternalPath.test(url) ? url : vAPI.getURL(url); + + if ( typeof onError !== 'function' ) { + onError = onLoad; + } + + // https://github.com/gorhill/uMatrix/issues/15 + var onResponseReceived = function() { + this.onload = this.onerror = this.ontimeout = null; + // xhr for local files gives status 0, but actually succeeds + var details = { + url: url, + content: '', + statusCode: this.status || 200, + statusText: this.statusText || '' + }; + if ( details.statusCode < 200 || details.statusCode >= 300 ) { + return onError.call(null, details); + } + // consider an empty result to be an error + if ( stringIsNotEmpty(this.responseText) === false ) { + return onError.call(null, details); + } + // we never download anything else than plain text: discard if response + // appears to be a HTML document: could happen when server serves + // some kind of error page I suppose + var text = this.responseText.trim(); + if ( text.startsWith('<') && text.endsWith('>') ) { + return onError.call(null, details); + } + details.content = this.responseText; + return onLoad.call(null, details); + }; + + var onErrorReceived = function() { + this.onload = this.onerror = this.ontimeout = null; + µMatrix.logger.writeOne('', 'error', errorCantConnectTo.replace('{{msg}}', actualUrl)); + onError.call(null, { url: url, content: '' }); + }; + + // Be ready for thrown exceptions: + // I am pretty sure it used to work, but now using a URL such as + // `file:///` on Chromium 40 results in an exception being thrown. + var xhr = new XMLHttpRequest(); + try { + xhr.open('get', actualUrl, true); + xhr.timeout = 30000; + xhr.onload = onResponseReceived; + xhr.onerror = onErrorReceived; + xhr.ontimeout = onErrorReceived; + xhr.responseType = 'text'; + xhr.send(); + } catch (e) { + onErrorReceived.call(xhr); + } +}; + +/******************************************************************************* + + TODO(seamless migration): + This block of code will be removed when I am confident all users have + moved to a version of uBO which does not require the old way of caching + assets. + + api.listKeyAliases: a map of old asset keys to new asset keys. + + migrate(): to seamlessly migrate the old cache manager to the new one: + - attempt to preserve and move content of cached assets to new locations; + - removes all traces of now obsolete cache manager entries in cacheStorage. + + This code will typically execute only once, when the newer version of uBO + is first installed and executed. + +**/ + +api.listKeyAliases = { + "assets/thirdparties/publicsuffix.org/list/effective_tld_names.dat": "public_suffix_list.dat", + "assets/thirdparties/hosts-file.net/ad-servers": "hphosts", + "assets/thirdparties/www.malwaredomainlist.com/hostslist/hosts.txt": "malware-0", + "assets/thirdparties/mirror1.malwaredomains.com/files/justdomains": "malware-1", + "assets/thirdparties/pgl.yoyo.org/as/serverlist": "plowe-0", + "assets/thirdparties/someonewhocares.org/hosts/hosts": "dpollock-0", + "assets/thirdparties/winhelp2002.mvps.org/hosts.txt": "mvps-0" +}; + +var migrate = function(callback) { + var entries, + moveCount = 0, + toRemove = []; + + var countdown = function(change) { + moveCount -= (change || 0); + if ( moveCount !== 0 ) { return; } + vAPI.cacheStorage.remove(toRemove); + saveAssetCacheRegistry(); + callback(); + }; + + var onContentRead = function(oldKey, newKey, bin) { + var content = bin && bin['cached_asset_content://' + oldKey] || undefined; + if ( content ) { + assetCacheRegistry[newKey] = { + readTime: Date.now(), + writeTime: entries[oldKey] + }; + if ( reIsExternalPath.test(oldKey) ) { + assetCacheRegistry[newKey].remoteURL = oldKey; + } + bin = {}; + bin['cache/' + newKey] = content; + vAPI.cacheStorage.set(bin); + } + countdown(1); + }; + + var onEntries = function(bin) { + entries = bin && bin.cached_asset_entries; + if ( !entries ) { return callback(); } + if ( bin && bin.assetCacheRegistry ) { + assetCacheRegistry = bin.assetCacheRegistry; + } + var aliases = api.listKeyAliases; + for ( var oldKey in entries ) { + var newKey = aliases[oldKey]; + if ( !newKey && /^https?:\/\//.test(oldKey) ) { + newKey = oldKey; + } + if ( newKey ) { + vAPI.cacheStorage.get( + 'cached_asset_content://' + oldKey, + onContentRead.bind(null, oldKey, newKey) + ); + moveCount += 1; + } + toRemove.push('cached_asset_content://' + oldKey); + } + toRemove.push('cached_asset_entries', 'extensionLastVersion'); + countdown(); + }; + + vAPI.cacheStorage.get( + [ 'cached_asset_entries', 'assetCacheRegistry' ], + onEntries + ); +}; + +/******************************************************************************* + + The purpose of the asset source registry is to keep key detail information + about an asset: + - Where to load it from: this may consist of one or more URLs, either local + or remote. + - After how many days an asset should be deemed obsolete -- i.e. in need of + an update. + - The origin and type of an asset. + - The last time an asset was registered. + +**/ + +var assetSourceRegistryStatus, + assetSourceRegistry = Object.create(null); + +var registerAssetSource = function(assetKey, dict) { + var entry = assetSourceRegistry[assetKey] || {}; + for ( var prop in dict ) { + if ( dict.hasOwnProperty(prop) === false ) { continue; } + if ( dict[prop] === undefined ) { + delete entry[prop]; + } else { + entry[prop] = dict[prop]; + } + } + var contentURL = dict.contentURL; + if ( contentURL !== undefined ) { + if ( typeof contentURL === 'string' ) { + contentURL = entry.contentURL = [ contentURL ]; + } else if ( Array.isArray(contentURL) === false ) { + contentURL = entry.contentURL = []; + } + var remoteURLCount = 0; + for ( var i = 0; i < contentURL.length; i++ ) { + if ( reIsExternalPath.test(contentURL[i]) ) { + remoteURLCount += 1; + } + } + entry.hasLocalURL = remoteURLCount !== contentURL.length; + entry.hasRemoteURL = remoteURLCount !== 0; + } else if ( entry.contentURL === undefined ) { + entry.contentURL = []; + } + if ( typeof entry.updateAfter !== 'number' ) { + entry.updateAfter = 13; + } + if ( entry.submitter ) { + entry.submitTime = Date.now(); // To detect stale entries + } + assetSourceRegistry[assetKey] = entry; +}; + +var unregisterAssetSource = function(assetKey) { + assetCacheRemove(assetKey); + delete assetSourceRegistry[assetKey]; +}; + +var saveAssetSourceRegistry = (function() { + var timer; + var save = function() { + timer = undefined; + vAPI.cacheStorage.set({ assetSourceRegistry: assetSourceRegistry }); + }; + return function(lazily) { + if ( timer !== undefined ) { + clearTimeout(timer); + } + if ( lazily ) { + timer = vAPI.setTimeout(save, 500); + } else { + save(); + } + }; +})(); + +var updateAssetSourceRegistry = function(json, silent) { + var newDict; + try { + newDict = JSON.parse(json); + } catch (ex) { + } + if ( newDict instanceof Object === false ) { return; } + + var oldDict = assetSourceRegistry, + assetKey; + + // Remove obsolete entries (only those which were built-in). + for ( assetKey in oldDict ) { + if ( + newDict[assetKey] === undefined && + oldDict[assetKey].submitter === undefined + ) { + unregisterAssetSource(assetKey); + } + } + // Add/update existing entries. Notify of new asset sources. + for ( assetKey in newDict ) { + if ( oldDict[assetKey] === undefined && !silent ) { + fireNotification( + 'builtin-asset-source-added', + { assetKey: assetKey, entry: newDict[assetKey] } + ); + } + registerAssetSource(assetKey, newDict[assetKey]); + } + saveAssetSourceRegistry(); +}; + +var getAssetSourceRegistry = function(callback) { + // Already loaded. + if ( assetSourceRegistryStatus === 'ready' ) { + callback(assetSourceRegistry); + return; + } + + // Being loaded. + if ( Array.isArray(assetSourceRegistryStatus) ) { + assetSourceRegistryStatus.push(callback); + return; + } + + // Not loaded: load it. + assetSourceRegistryStatus = [ callback ]; + + var registryReady = function() { + var callers = assetSourceRegistryStatus; + assetSourceRegistryStatus = 'ready'; + var fn; + while ( (fn = callers.shift()) ) { + fn(assetSourceRegistry); + } + }; + + // First-install case. + var createRegistry = function() { + api.fetchText( + µMatrix.assetsBootstrapLocation || 'assets/assets.json', + function(details) { + updateAssetSourceRegistry(details.content, true); + registryReady(); + } + ); + }; + + vAPI.cacheStorage.get('assetSourceRegistry', function(bin) { + if ( !bin || !bin.assetSourceRegistry ) { + createRegistry(); + return; + } + assetSourceRegistry = bin.assetSourceRegistry; + registryReady(); + }); +}; + +api.registerAssetSource = function(assetKey, details) { + getAssetSourceRegistry(function() { + registerAssetSource(assetKey, details); + saveAssetSourceRegistry(true); + }); +}; + +api.unregisterAssetSource = function(assetKey) { + getAssetSourceRegistry(function() { + unregisterAssetSource(assetKey); + saveAssetSourceRegistry(true); + }); +}; + +/******************************************************************************* + + The purpose of the asset cache registry is to keep track of all assets + which have been persisted into the local cache. + +**/ + +var assetCacheRegistryStatus, + assetCacheRegistryStartTime = Date.now(), + assetCacheRegistry = {}; + +var getAssetCacheRegistry = function(callback) { + // Already loaded. + if ( assetCacheRegistryStatus === 'ready' ) { + callback(assetCacheRegistry); + return; + } + + // Being loaded. + if ( Array.isArray(assetCacheRegistryStatus) ) { + assetCacheRegistryStatus.push(callback); + return; + } + + // Not loaded: load it. + assetCacheRegistryStatus = [ callback ]; + + var registryReady = function() { + var callers = assetCacheRegistryStatus; + assetCacheRegistryStatus = 'ready'; + var fn; + while ( (fn = callers.shift()) ) { + fn(assetCacheRegistry); + } + }; + + var migrationDone = function() { + vAPI.cacheStorage.get('assetCacheRegistry', function(bin) { + if ( bin && bin.assetCacheRegistry ) { + assetCacheRegistry = bin.assetCacheRegistry; + } + registryReady(); + }); + }; + + migrate(migrationDone); +}; + +var saveAssetCacheRegistry = (function() { + var timer; + var save = function() { + timer = undefined; + vAPI.cacheStorage.set({ assetCacheRegistry: assetCacheRegistry }); + }; + return function(lazily) { + if ( timer !== undefined ) { clearTimeout(timer); } + if ( lazily ) { + timer = vAPI.setTimeout(save, 500); + } else { + save(); + } + }; +})(); + +var assetCacheRead = function(assetKey, callback) { + var internalKey = 'cache/' + assetKey; + + var reportBack = function(content, err) { + var details = { assetKey: assetKey, content: content }; + if ( err ) { details.error = err; } + callback(details); + }; + + var onAssetRead = function(bin) { + if ( !bin || !bin[internalKey] ) { + return reportBack('', 'E_NOTFOUND'); + } + var entry = assetCacheRegistry[assetKey]; + if ( entry === undefined ) { + return reportBack('', 'E_NOTFOUND'); + } + entry.readTime = Date.now(); + saveAssetCacheRegistry(true); + reportBack(bin[internalKey]); + }; + + var onReady = function() { + vAPI.cacheStorage.get(internalKey, onAssetRead); + }; + + getAssetCacheRegistry(onReady); +}; + +var assetCacheWrite = function(assetKey, details, callback) { + var internalKey = 'cache/' + assetKey; + var content = ''; + if ( typeof details === 'string' ) { + content = details; + } else if ( details instanceof Object ) { + content = details.content || ''; + } + + if ( content === '' ) { + return assetCacheRemove(assetKey, callback); + } + + var reportBack = function(content) { + var details = { assetKey: assetKey, content: content }; + if ( typeof callback === 'function' ) { + callback(details); + } + fireNotification('after-asset-updated', details); + }; + + var onReady = function() { + var entry = assetCacheRegistry[assetKey]; + if ( entry === undefined ) { + entry = assetCacheRegistry[assetKey] = {}; + } + entry.writeTime = entry.readTime = Date.now(); + if ( details instanceof Object && typeof details.url === 'string' ) { + entry.remoteURL = details.url; + } + var bin = { assetCacheRegistry: assetCacheRegistry }; + bin[internalKey] = content; + vAPI.cacheStorage.set(bin); + reportBack(content); + }; + getAssetCacheRegistry(onReady); +}; + +var assetCacheRemove = function(pattern, callback) { + var onReady = function() { + var cacheDict = assetCacheRegistry, + removedEntries = [], + removedContent = []; + for ( var assetKey in cacheDict ) { + if ( pattern instanceof RegExp && !pattern.test(assetKey) ) { + continue; + } + if ( typeof pattern === 'string' && assetKey !== pattern ) { + continue; + } + removedEntries.push(assetKey); + removedContent.push('cache/' + assetKey); + delete cacheDict[assetKey]; + } + if ( removedContent.length !== 0 ) { + vAPI.cacheStorage.remove(removedContent); + var bin = { assetCacheRegistry: assetCacheRegistry }; + vAPI.cacheStorage.set(bin); + } + if ( typeof callback === 'function' ) { + callback(); + } + for ( var i = 0; i < removedEntries.length; i++ ) { + fireNotification('after-asset-updated', { assetKey: removedEntries[i] }); + } + }; + + getAssetCacheRegistry(onReady); +}; + +var assetCacheMarkAsDirty = function(pattern, exclude, callback) { + var onReady = function() { + var cacheDict = assetCacheRegistry, + cacheEntry, + mustSave = false; + for ( var assetKey in cacheDict ) { + if ( pattern instanceof RegExp ) { + if ( pattern.test(assetKey) === false ) { continue; } + } else if ( typeof pattern === 'string' ) { + if ( assetKey !== pattern ) { continue; } + } else if ( Array.isArray(pattern) ) { + if ( pattern.indexOf(assetKey) === -1 ) { continue; } + } + if ( exclude instanceof RegExp ) { + if ( exclude.test(assetKey) ) { continue; } + } else if ( typeof exclude === 'string' ) { + if ( assetKey === exclude ) { continue; } + } else if ( Array.isArray(exclude) ) { + if ( exclude.indexOf(assetKey) !== -1 ) { continue; } + } + cacheEntry = cacheDict[assetKey]; + if ( !cacheEntry.writeTime ) { continue; } + cacheDict[assetKey].writeTime = 0; + mustSave = true; + } + if ( mustSave ) { + var bin = { assetCacheRegistry: assetCacheRegistry }; + vAPI.cacheStorage.set(bin); + } + if ( typeof callback === 'function' ) { + callback(); + } + }; + if ( typeof exclude === 'function' ) { + callback = exclude; + exclude = undefined; + } + getAssetCacheRegistry(onReady); +}; + +/******************************************************************************/ + +var stringIsNotEmpty = function(s) { + return typeof s === 'string' && s !== ''; +}; + +/******************************************************************************/ + +api.get = function(assetKey, options, callback) { + if ( typeof options === 'function' ) { + callback = options; + options = {}; + } else if ( typeof callback !== 'function' ) { + callback = noopfunc; + } + + var assetDetails = {}, + contentURLs, + contentURL; + + var reportBack = function(content, err) { + var details = { assetKey: assetKey, content: content }; + if ( err ) { + details.error = assetDetails.lastError = err; + } else { + assetDetails.lastError = undefined; + } + callback(details); + }; + + var onContentNotLoaded = function() { + var isExternal; + while ( (contentURL = contentURLs.shift()) ) { + isExternal = reIsExternalPath.test(contentURL); + if ( isExternal === false || assetDetails.hasLocalURL !== true ) { + break; + } + } + if ( !contentURL ) { + return reportBack('', 'E_NOTFOUND'); + } + api.fetchText(contentURL, onContentLoaded, onContentNotLoaded); + }; + + var onContentLoaded = function(details) { + if ( stringIsNotEmpty(details.content) === false ) { + onContentNotLoaded(); + return; + } + if ( reIsExternalPath.test(contentURL) && options.dontCache !== true ) { + assetCacheWrite(assetKey, { + content: details.content, + url: contentURL + }); + } + reportBack(details.content); + }; + + var onCachedContentLoaded = function(details) { + if ( details.content !== '' ) { + return reportBack(details.content); + } + getAssetSourceRegistry(function(registry) { + assetDetails = registry[assetKey] || {}; + if ( typeof assetDetails.contentURL === 'string' ) { + contentURLs = [ assetDetails.contentURL ]; + } else if ( Array.isArray(assetDetails.contentURL) ) { + contentURLs = assetDetails.contentURL.slice(0); + } else { + contentURLs = []; + } + onContentNotLoaded(); + }); + }; + + assetCacheRead(assetKey, onCachedContentLoaded); +}; + +/******************************************************************************/ + +var getRemote = function(assetKey, callback) { + var assetDetails = {}, + contentURLs, + contentURL; + + var reportBack = function(content, err) { + var details = { assetKey: assetKey, content: content }; + if ( err ) { + details.error = assetDetails.lastError = err; + } else { + assetDetails.lastError = undefined; + } + callback(details); + }; + + var onRemoteContentLoaded = function(details) { + if ( stringIsNotEmpty(details.content) === false ) { + registerAssetSource(assetKey, { error: { time: Date.now(), error: 'No content' } }); + tryLoading(); + return; + } + assetCacheWrite(assetKey, { + content: details.content, + url: contentURL + }); + registerAssetSource(assetKey, { error: undefined }); + reportBack(details.content); + }; + + var onRemoteContentError = function(details) { + var text = details.statusText; + if ( details.statusCode === 0 ) { + text = 'network error'; + } + registerAssetSource(assetKey, { error: { time: Date.now(), error: text } }); + tryLoading(); + }; + + var tryLoading = function() { + while ( (contentURL = contentURLs.shift()) ) { + if ( reIsExternalPath.test(contentURL) ) { break; } + } + if ( !contentURL ) { + return reportBack('', 'E_NOTFOUND'); + } + api.fetchText(contentURL, onRemoteContentLoaded, onRemoteContentError); + }; + + getAssetSourceRegistry(function(registry) { + assetDetails = registry[assetKey] || {}; + if ( typeof assetDetails.contentURL === 'string' ) { + contentURLs = [ assetDetails.contentURL ]; + } else if ( Array.isArray(assetDetails.contentURL) ) { + contentURLs = assetDetails.contentURL.slice(0); + } else { + contentURLs = []; + } + tryLoading(); + }); +}; + +/******************************************************************************/ + +api.put = function(assetKey, content, callback) { + assetCacheWrite(assetKey, content, callback); +}; + +/******************************************************************************/ + +api.metadata = function(callback) { + var assetRegistryReady = false, + cacheRegistryReady = false; + + var onReady = function() { + var assetDict = JSON.parse(JSON.stringify(assetSourceRegistry)), + cacheDict = assetCacheRegistry, + assetEntry, cacheEntry, + now = Date.now(), obsoleteAfter; + for ( var assetKey in assetDict ) { + assetEntry = assetDict[assetKey]; + cacheEntry = cacheDict[assetKey]; + if ( cacheEntry ) { + assetEntry.cached = true; + assetEntry.writeTime = cacheEntry.writeTime; + obsoleteAfter = cacheEntry.writeTime + assetEntry.updateAfter * 86400000; + assetEntry.obsolete = obsoleteAfter < now; + assetEntry.remoteURL = cacheEntry.remoteURL; + } else { + assetEntry.writeTime = 0; + obsoleteAfter = 0; + assetEntry.obsolete = true; + } + } + callback(assetDict); + }; + + getAssetSourceRegistry(function() { + assetRegistryReady = true; + if ( cacheRegistryReady ) { onReady(); } + }); + + getAssetCacheRegistry(function() { + cacheRegistryReady = true; + if ( assetRegistryReady ) { onReady(); } + }); +}; + +/******************************************************************************/ + +api.purge = assetCacheMarkAsDirty; + +api.remove = function(pattern, callback) { + assetCacheRemove(pattern, callback); +}; + +api.rmrf = function() { + assetCacheRemove(/./); +}; + +/******************************************************************************/ + +// Asset updater area. +var updaterStatus, + updaterTimer, + updaterAssetDelayDefault = 120000, + updaterAssetDelay = updaterAssetDelayDefault, + updaterUpdated = [], + updaterFetched = new Set(); + +var updateFirst = function() { + updaterStatus = 'updating'; + updaterFetched.clear(); + updaterUpdated = []; + fireNotification('before-assets-updated'); + updateNext(); +}; + +var updateNext = function() { + var assetDict, cacheDict; + + // This will remove a cached asset when it's no longer in use. + var garbageCollectOne = function(assetKey) { + var cacheEntry = cacheDict[assetKey]; + if ( cacheEntry && cacheEntry.readTime < assetCacheRegistryStartTime ) { + assetCacheRemove(assetKey); + } + }; + + var findOne = function() { + var now = Date.now(), + assetEntry, cacheEntry; + for ( var assetKey in assetDict ) { + assetEntry = assetDict[assetKey]; + if ( assetEntry.hasRemoteURL !== true ) { continue; } + if ( updaterFetched.has(assetKey) ) { continue; } + cacheEntry = cacheDict[assetKey]; + if ( cacheEntry && (cacheEntry.writeTime + assetEntry.updateAfter * 86400000) > now ) { + continue; + } + if ( fireNotification('before-asset-updated', { assetKey: assetKey }) !== false ) { + return assetKey; + } + garbageCollectOne(assetKey); + } + }; + + var updatedOne = function(details) { + if ( details.content !== '' ) { + updaterUpdated.push(details.assetKey); + if ( details.assetKey === 'assets.json' ) { + updateAssetSourceRegistry(details.content); + } + } else { + fireNotification('asset-update-failed', { assetKey: details.assetKey }); + } + if ( findOne() !== undefined ) { + vAPI.setTimeout(updateNext, updaterAssetDelay); + } else { + updateDone(); + } + }; + + var updateOne = function() { + var assetKey = findOne(); + if ( assetKey === undefined ) { + return updateDone(); + } + updaterFetched.add(assetKey); + getRemote(assetKey, updatedOne); + }; + + getAssetSourceRegistry(function(dict) { + assetDict = dict; + if ( !cacheDict ) { return; } + updateOne(); + }); + + getAssetCacheRegistry(function(dict) { + cacheDict = dict; + if ( !assetDict ) { return; } + updateOne(); + }); +}; + +var updateDone = function() { + var assetKeys = updaterUpdated.slice(0); + updaterFetched.clear(); + updaterUpdated = []; + updaterStatus = undefined; + updaterAssetDelay = updaterAssetDelayDefault; + fireNotification('after-assets-updated', { assetKeys: assetKeys }); +}; + +api.updateStart = function(details) { + var oldUpdateDelay = updaterAssetDelay, + newUpdateDelay = details.delay || updaterAssetDelayDefault; + updaterAssetDelay = Math.min(oldUpdateDelay, newUpdateDelay); + if ( updaterStatus !== undefined ) { + if ( newUpdateDelay < oldUpdateDelay ) { + clearTimeout(updaterTimer); + updaterTimer = vAPI.setTimeout(updateNext, updaterAssetDelay); + } + return; + } + updateFirst(); +}; + +api.updateStop = function() { + if ( updaterTimer ) { + clearTimeout(updaterTimer); + updaterTimer = undefined; + } + if ( updaterStatus !== undefined ) { + updateDone(); + } +}; + +/******************************************************************************/ + +return api; + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/js/background.js b/js/background.js new file mode 100644 index 0000000..1a46616 --- /dev/null +++ b/js/background.js @@ -0,0 +1,246 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +'use strict'; + +/******************************************************************************/ + +var µMatrix = (function() { // jshint ignore:line + +/******************************************************************************/ + +var oneSecond = 1000; +var oneMinute = 60 * oneSecond; +var oneHour = 60 * oneMinute; +var oneDay = 24 * oneHour; + +/******************************************************************************/ +/******************************************************************************/ + +var _RequestStats = function() { + this.reset(); +}; + +_RequestStats.prototype.reset = function() { + this.all = + this.doc = + this.frame = + this.script = + this.css = + this.image = + this.media = + this.xhr = + this.other = + this.cookie = 0; +}; + +/******************************************************************************/ + +var RequestStats = function() { + this.allowed = new _RequestStats(); + this.blocked = new _RequestStats(); +}; + +RequestStats.prototype.reset = function() { + this.blocked.reset(); + this.allowed.reset(); +}; + +RequestStats.prototype.record = function(type, blocked) { + // Remember: always test against **false** + if ( blocked !== false ) { + this.blocked[type] += 1; + this.blocked.all += 1; + } else { + this.allowed[type] += 1; + this.allowed.all += 1; + } +}; + +var requestStatsFactory = function() { + return new RequestStats(); +}; + +/******************************************************************************* + + SVG-based icons below were extracted from + fontawesome-webfont.svg v4.7. Excerpt of copyright notice at + the top of the file: + + > Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + > By ,,, + > Copyright Dave Gandy 2016. All rights reserved. + + Excerpt of the license information in the fontawesome CSS + file bundled with the package: + + > Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome + > License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + + Font icons: + - glyph-name: "external_link" + +*/ + +var rawSettingsDefault = { + disableCSPReportInjection: false, + placeholderBackground: + [ + 'url("data:image/png;base64,', + 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAK', + 'CAAAAACoWZBhAAAABGdBTUEAALGPC/xh', + 'BQAAAAJiS0dEAP+Hj8y/AAAAB3RJTUUH', + '3wwIAAgyL/YaPAAAACJJREFUCFtjfMbO', + 'AAQ/gZiFnQPEBAEmGIMIJgtIL8QEgtoA', + 'In4D/96X1KAAAAAldEVYdGRhdGU6Y3Jl', + 'YXRlADIwMTUtMTItMDhUMDA6MDg6NTAr', + 'MDM6MDAasuuJAAAAJXRFWHRkYXRlOm1v', + 'ZGlmeQAyMDE1LTEyLTA4VDAwOjA4OjUw', + 'KzAzOjAwa+9TNQAAAABJRU5ErkJggg==', + '") ', + 'repeat scroll #fff' + ].join(''), + placeholderBorder: '1px solid rgba(0, 0, 0, 0.1)', + imagePlaceholder: true, + imagePlaceholderBackground: 'default', + imagePlaceholderBorder: 'default', + framePlaceholder: true, + framePlaceholderDocument: + [ + '<html><head>', + '<meta charset="utf-8">', + '<style>', + 'body { ', + 'background: {{bg}};', + 'color: gray;', + 'font: 12px sans-serif;', + 'margin: 0;', + 'overflow: hidden;', + 'padding: 2px;', + 'white-space: nowrap;', + '}', + 'a { ', + 'color: inherit;', + 'padding: 0 3px;', + 'text-decoration: none;', + '}', + 'svg {', + 'display: inline-block;', + 'fill: gray;', + 'height: 12px;', + 'vertical-align: bottom;', + 'width: 12px;', + '}', + '</style></head><body>', + '<span><a href="{{url}}" title="{{url}}" target="_blank">', + '<svg viewBox="0 0 1792 1792"><path transform="scale(1,-1) translate(0,-1536)" d="M1408 608v-320q0 -119 -84.5 -203.5t-203.5 -84.5h-832q-119 0 -203.5 84.5t-84.5 203.5v832q0 119 84.5 203.5t203.5 84.5h704q14 0 23 -9t9 -23v-64q0 -14 -9 -23t-23 -9h-704q-66 0 -113 -47t-47 -113v-832q0 -66 47 -113t113 -47h832q66 0 113 47t47 113v320q0 14 9 23t23 9h64q14 0 23 -9t9 -23zM1792 1472v-512q0 -26 -19 -45t-45 -19t-45 19l-176 176l-652 -652q-10 -10 -23 -10t-23 10l-114 114q-10 10 -10 23t10 23l652 652l-176 176q-19 19 -19 45t19 45t45 19h512q26 0 45 -19t19 -45z" /></svg>', + '</a>{{url}}</span>', + '</body></html>' + ].join(''), + framePlaceholderBackground: 'default', +}; + +/******************************************************************************/ + +return { + onBeforeStartQueue: [], + + userSettings: { + alwaysDetachLogger: false, + autoUpdate: false, + clearBrowserCache: true, + clearBrowserCacheAfter: 60, + cloudStorageEnabled: false, + collapseBlacklisted: true, + collapseBlocked: false, + colorBlindFriendly: false, + deleteCookies: false, + deleteUnusedSessionCookies: false, + deleteUnusedSessionCookiesAfter: 60, + deleteLocalStorage: false, + displayTextSize: '14px', + externalHostsFiles: '', + iconBadgeEnabled: false, + maxLoggedRequests: 1000, + popupCollapseAllDomains: false, + popupCollapseBlacklistedDomains: false, + popupScopeLevel: 'domain', + processHyperlinkAuditing: true, + processReferer: false + }, + + rawSettingsDefault: rawSettingsDefault, + rawSettings: Object.assign({}, rawSettingsDefault), + rawSettingsWriteTime: 0, + + clearBrowserCacheCycle: 0, + cspNoInlineScript: "script-src 'unsafe-eval' blob: *", + cspNoInlineStyle: "style-src blob: *", + cspNoWorker: undefined, + updateAssetsEvery: 11 * oneDay + 1 * oneHour + 1 * oneMinute + 1 * oneSecond, + firstUpdateAfter: 11 * oneMinute, + nextUpdateAfter: 11 * oneHour, + assetsBootstrapLocation: 'assets/assets.json', + pslAssetKey: 'public_suffix_list.dat', + + // list of live hosts files + liveHostsFiles: { + }, + + // urls stats are kept on the back burner while waiting to be reactivated + // in a tab or another. + pageStores: {}, + pageStoresToken: 0, + pageStoreCemetery: {}, + + // page url => permission scope + tMatrix: null, + pMatrix: null, + + ubiquitousBlacklist: null, + + // various stats + requestStatsFactory: requestStatsFactory, + requestStats: requestStatsFactory(), + cookieRemovedCounter: 0, + localStorageRemovedCounter: 0, + cookieHeaderFoiledCounter: 0, + refererHeaderFoiledCounter: 0, + hyperlinkAuditingFoiledCounter: 0, + browserCacheClearedCounter: 0, + storageUsed: 0, + + // record what the browser is doing behind the scene + behindTheSceneScope: 'behind-the-scene', + + noopFunc: function(){}, + + // so that I don't have to care for last comma + dummy: 0 +}; + +/******************************************************************************/ + +})(); + +/******************************************************************************/ + diff --git a/js/browsercache.js b/js/browsercache.js new file mode 100644 index 0000000..3316a63 --- /dev/null +++ b/js/browsercache.js @@ -0,0 +1,65 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2015-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global µMatrix */ + +/******************************************************************************/ + +(function() { + +'use strict'; + +/******************************************************************************/ + +// Browser data jobs + +var clearCache = function() { + vAPI.setTimeout(clearCache, 15 * 60 * 1000); + + var µm = µMatrix; + if ( !µm.userSettings.clearBrowserCache ) { + return; + } + + µm.clearBrowserCacheCycle -= 15; + if ( µm.clearBrowserCacheCycle > 0 ) { + return; + } + + vAPI.browserData.clearCache(); + + µm.clearBrowserCacheCycle = µm.userSettings.clearBrowserCacheAfter; + µm.browserCacheClearedCounter++; + + // TODO: i18n + µm.logger.writeOne('', 'info', vAPI.i18n('loggerEntryBrowserCacheCleared')); + + //console.debug('clearBrowserCacheCallback()> vAPI.browserData.clearCache() called'); +}; + +vAPI.setTimeout(clearCache, 15 * 60 * 1000); + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/js/cloud-ui.js b/js/cloud-ui.js new file mode 100644 index 0000000..a017ae9 --- /dev/null +++ b/js/cloud-ui.js @@ -0,0 +1,214 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2015-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uBlock +*/ + +/* global uDom */ + +'use strict'; + +/******************************************************************************/ + +(function() { + +/******************************************************************************/ + +self.cloud = { + options: {}, + datakey: '', + data: undefined, + onPush: null, + onPull: null +}; + +/******************************************************************************/ + +var widget = uDom.nodeFromId('cloudWidget'); +if ( widget === null ) { + return; +} + +self.cloud.datakey = widget.getAttribute('data-cloud-entry') || ''; +if ( self.cloud.datakey === '' ) { + return; +} + +/******************************************************************************/ + +var onCloudDataReceived = function(entry) { + if ( typeof entry !== 'object' || entry === null ) { + return; + } + + self.cloud.data = entry.data; + + uDom.nodeFromId('cloudPull').removeAttribute('disabled'); + uDom.nodeFromId('cloudPullAndMerge').removeAttribute('disabled'); + + var timeOptions = { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + timeZoneName: 'short' + }; + + var time = new Date(entry.tstamp); + widget.querySelector('span').textContent = + entry.source + '\n' + + time.toLocaleString('fullwide', timeOptions); +}; + +/******************************************************************************/ + +var fetchCloudData = function() { + vAPI.messaging.send( + 'cloud-ui.js', + { + what: 'cloudPull', + datakey: self.cloud.datakey + }, + onCloudDataReceived + ); +}; + +/******************************************************************************/ + +var pushData = function() { + if ( typeof self.cloud.onPush !== 'function' ) { + return; + } + vAPI.messaging.send( + 'cloud-ui.js', + { + what: 'cloudPush', + datakey: self.cloud.datakey, + data: self.cloud.onPush() + }, + fetchCloudData + ); +}; + +/******************************************************************************/ + +var pullData = function(ev) { + if ( typeof self.cloud.onPull === 'function' ) { + self.cloud.onPull(self.cloud.data, ev.shiftKey); + } +}; + +/******************************************************************************/ + +var pullAndMergeData = function() { + if ( typeof self.cloud.onPull === 'function' ) { + self.cloud.onPull(self.cloud.data, true); + } +}; + +/******************************************************************************/ + +var openOptions = function() { + var input = uDom.nodeFromId('cloudDeviceName'); + input.value = self.cloud.options.deviceName; + input.setAttribute('placeholder', self.cloud.options.defaultDeviceName); + uDom.nodeFromId('cloudOptions').classList.add('show'); +}; + +/******************************************************************************/ + +var closeOptions = function(ev) { + var root = uDom.nodeFromId('cloudOptions'); + if ( ev.target !== root ) { + return; + } + root.classList.remove('show'); +}; + +/******************************************************************************/ + +var submitOptions = function() { + var onOptions = function(options) { + if ( typeof options !== 'object' || options === null ) { + return; + } + self.cloud.options = options; + }; + + vAPI.messaging.send('cloud-ui.js', { + what: 'cloudSetOptions', + options: { + deviceName: uDom.nodeFromId('cloudDeviceName').value + } + }, onOptions); + uDom.nodeFromId('cloudOptions').classList.remove('show'); +}; + +/******************************************************************************/ + +var onInitialize = function(options) { + if ( typeof options !== 'object' || options === null ) { + return; + } + + if ( !options.enabled ) { + return; + } + self.cloud.options = options; + + var xhr = new XMLHttpRequest(); + xhr.open('GET', 'cloud-ui.html', true); + xhr.overrideMimeType('text/html;charset=utf-8'); + xhr.responseType = 'text'; + xhr.onload = function() { + this.onload = null; + var parser = new DOMParser(), + parsed = parser.parseFromString(this.responseText, 'text/html'), + fromParent = parsed.body; + while ( fromParent.firstElementChild !== null ) { + widget.appendChild( + document.adoptNode(fromParent.firstElementChild) + ); + } + + vAPI.i18n.render(widget); + widget.classList.remove('hide'); + + uDom('#cloudPush').on('click', pushData); + uDom('#cloudPull').on('click', pullData); + uDom('#cloudPullAndMerge').on('click', pullAndMergeData); + uDom('#cloudCog').on('click', openOptions); + uDom('#cloudOptions').on('click', closeOptions); + uDom('#cloudOptionsSubmit').on('click', submitOptions); + + fetchCloudData(); + }; + xhr.send(); +}; + +vAPI.messaging.send('cloud-ui.js', { what: 'cloudGetOptions' }, onInitialize); + +/******************************************************************************/ + +// https://www.youtube.com/watch?v=aQFp67VoiDA + +})(); diff --git a/js/contentscript-start.js b/js/contentscript-start.js new file mode 100644 index 0000000..c449c55 --- /dev/null +++ b/js/contentscript-start.js @@ -0,0 +1,97 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2017-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +'use strict'; + +/******************************************************************************/ +/******************************************************************************/ + +// Injected into content pages + +(function() { + + if ( typeof vAPI !== 'object' ) { return; } + + vAPI.selfWorkerSrcReported = vAPI.selfWorkerSrcReported || false; + + var reGoodWorkerSrc = /(?:child|worker)-src[^;,]+?'none'/; + + var handler = function(ev) { + if ( + ev.isTrusted !== true || + ev.originalPolicy.includes('report-uri about:blank') === false + ) { + return false; + } + + // Firefox and Chromium differs in how they fill the + // 'effectiveDirective' property. + if ( + ev.effectiveDirective.startsWith('worker-src') === false && + ev.effectiveDirective.startsWith('child-src') === false + ) { + return false; + } + + // Further validate that the policy violation is relevant to uMatrix: + // the event still could have been fired as a result of a CSP header + // not injected by uMatrix. + if ( reGoodWorkerSrc.test(ev.originalPolicy) === false ) { + return false; + } + + // We do not want to report internal resources more than once. + // However, we do want to report external resources each time. + // TODO: this could eventually lead to duplicated reports for external + // resources if another extension uses the same approach as + // uMatrix. Think about what could be done to avoid duplicate + // reports. + if ( ev.blockedURI.includes('://') === false ) { + if ( vAPI.selfWorkerSrcReported ) { return true; } + vAPI.selfWorkerSrcReported = true; + } + + vAPI.messaging.send( + 'contentscript.js', + { + what: 'securityPolicyViolation', + directive: 'worker-src', + blockedURI: ev.blockedURI, + documentURI: ev.documentURI, + blocked: ev.disposition === 'enforce' + } + ); + + return true; + }; + + document.addEventListener( + 'securitypolicyviolation', + function(ev) { + if ( !handler(ev) ) { return; } + ev.stopPropagation(); + ev.preventDefault(); + }, + true + ); + +})(); diff --git a/js/contentscript.js b/js/contentscript.js new file mode 100644 index 0000000..dcdd473 --- /dev/null +++ b/js/contentscript.js @@ -0,0 +1,541 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global HTMLDocument, XMLDocument */ + +'use strict'; + +/******************************************************************************/ +/******************************************************************************/ + +// Injected into content pages + +(function() { + +/******************************************************************************/ + +// https://github.com/chrisaljoudi/uBlock/issues/464 +// https://github.com/gorhill/uMatrix/issues/621 +if ( + document instanceof HTMLDocument === false && + document instanceof XMLDocument === false +) { + return; +} + +// This can also happen (for example if script injected into a `data:` URI doc) +if ( !window.location ) { + return; +} + +// This can happen +if ( typeof vAPI !== 'object' ) { + //console.debug('contentscript.js > vAPI not found'); + return; +} + +// https://github.com/chrisaljoudi/uBlock/issues/456 +// Already injected? +if ( vAPI.contentscriptEndInjected ) { + //console.debug('contentscript.js > content script already injected'); + return; +} +vAPI.contentscriptEndInjected = true; + +/******************************************************************************/ +/******************************************************************************/ + +// Executed only once. + +(function() { + var localStorageHandler = function(mustRemove) { + if ( mustRemove ) { + window.localStorage.clear(); + window.sessionStorage.clear(); + } + }; + + // Check with extension whether local storage must be emptied + // rhill 2014-03-28: we need an exception handler in case 3rd-party access + // to site data is disabled. + // https://github.com/gorhill/httpswitchboard/issues/215 + try { + var hasLocalStorage = + window.localStorage && window.localStorage.length !== 0; + var hasSessionStorage = + window.sessionStorage && window.sessionStorage.length !== 0; + if ( hasLocalStorage || hasSessionStorage ) { + vAPI.messaging.send('contentscript.js', { + what: 'contentScriptHasLocalStorage', + originURL: window.location.origin + }, localStorageHandler); + } + + // TODO: indexedDB + //if ( window.indexedDB && !!window.indexedDB.webkitGetDatabaseNames ) { + // var db = window.indexedDB.webkitGetDatabaseNames().onsuccess = function(sender) { + // console.debug('webkitGetDatabaseNames(): result=%o', sender.target.result); + // }; + //} + + // TODO: Web SQL + // if ( window.openDatabase ) { + // Sad: + // "There is no way to enumerate or delete the databases available for an origin from this API." + // Ref.: http://www.w3.org/TR/webdatabase/#databases + // } + } + catch (e) { + } +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// https://github.com/gorhill/uMatrix/issues/45 + +var collapser = (function() { + var resquestIdGenerator = 1, + processTimer, + toProcess = [], + toFilter = [], + toCollapse = new Map(), + cachedBlockedMap, + cachedBlockedMapHash, + cachedBlockedMapTimer, + reURLPlaceholder = /\{\{url\}\}/g; + var src1stProps = { + 'embed': 'src', + 'iframe': 'src', + 'img': 'src', + 'object': 'data' + }; + var src2ndProps = { + 'img': 'srcset' + }; + var tagToTypeMap = { + embed: 'media', + iframe: 'frame', + img: 'image', + object: 'media' + }; + var cachedBlockedSetClear = function() { + cachedBlockedMap = + cachedBlockedMapHash = + cachedBlockedMapTimer = undefined; + }; + + // https://github.com/chrisaljoudi/uBlock/issues/174 + // Do not remove fragment from src URL + var onProcessed = function(response) { + if ( !response ) { // This happens if uBO is disabled or restarted. + toCollapse.clear(); + return; + } + + var targets = toCollapse.get(response.id); + if ( targets === undefined ) { return; } + toCollapse.delete(response.id); + if ( cachedBlockedMapHash !== response.hash ) { + cachedBlockedMap = new Map(response.blockedResources); + cachedBlockedMapHash = response.hash; + if ( cachedBlockedMapTimer !== undefined ) { + clearTimeout(cachedBlockedMapTimer); + } + cachedBlockedMapTimer = vAPI.setTimeout(cachedBlockedSetClear, 30000); + } + if ( cachedBlockedMap === undefined || cachedBlockedMap.size === 0 ) { + return; + } + + var placeholders = response.placeholders, + tag, prop, src, collapsed, docurl, replaced; + + for ( var target of targets ) { + tag = target.localName; + prop = src1stProps[tag]; + if ( prop === undefined ) { continue; } + src = target[prop]; + if ( typeof src !== 'string' || src.length === 0 ) { + prop = src2ndProps[tag]; + if ( prop === undefined ) { continue; } + src = target[prop]; + if ( typeof src !== 'string' || src.length === 0 ) { continue; } + } + collapsed = cachedBlockedMap.get(tagToTypeMap[tag] + ' ' + src); + if ( collapsed === undefined ) { continue; } + if ( collapsed ) { + target.style.setProperty('display', 'none', 'important'); + target.hidden = true; + continue; + } + switch ( tag ) { + case 'iframe': + if ( placeholders.frame !== true ) { break; } + docurl = + 'data:text/html,' + + encodeURIComponent( + placeholders.frameDocument.replace( + reURLPlaceholder, + src + ) + ); + replaced = false; + // Using contentWindow.location prevent tainting browser + // history -- i.e. breaking back button (seen on Chromium). + if ( target.contentWindow ) { + try { + target.contentWindow.location.replace(docurl); + replaced = true; + } catch(ex) { + } + } + if ( !replaced ) { + target.setAttribute('src', docurl); + } + break; + case 'img': + if ( placeholders.image !== true ) { break; } + target.style.setProperty('display', 'inline-block'); + target.style.setProperty('min-width', '20px', 'important'); + target.style.setProperty('min-height', '20px', 'important'); + target.style.setProperty( + 'border', + placeholders.imageBorder, + 'important' + ); + target.style.setProperty( + 'background', + placeholders.imageBackground, + 'important' + ); + break; + } + } + }; + + var send = function() { + processTimer = undefined; + toCollapse.set(resquestIdGenerator, toProcess); + var msg = { + what: 'lookupBlockedCollapsibles', + id: resquestIdGenerator, + toFilter: toFilter, + hash: cachedBlockedMapHash + }; + vAPI.messaging.send('contentscript.js', msg, onProcessed); + toProcess = []; + toFilter = []; + resquestIdGenerator += 1; + }; + + var process = function(delay) { + if ( toProcess.length === 0 ) { return; } + if ( delay === 0 ) { + if ( processTimer !== undefined ) { + clearTimeout(processTimer); + } + send(); + } else if ( processTimer === undefined ) { + processTimer = vAPI.setTimeout(send, delay || 47); + } + }; + + var add = function(target) { + toProcess.push(target); + }; + + var addMany = function(targets) { + var i = targets.length; + while ( i-- ) { + toProcess.push(targets[i]); + } + }; + + var iframeSourceModified = function(mutations) { + var i = mutations.length; + while ( i-- ) { + addIFrame(mutations[i].target, true); + } + process(); + }; + var iframeSourceObserver; + var iframeSourceObserverOptions = { + attributes: true, + attributeFilter: [ 'src' ] + }; + + var addIFrame = function(iframe, dontObserve) { + // https://github.com/gorhill/uBlock/issues/162 + // Be prepared to deal with possible change of src attribute. + if ( dontObserve !== true ) { + if ( iframeSourceObserver === undefined ) { + iframeSourceObserver = new MutationObserver(iframeSourceModified); + } + iframeSourceObserver.observe(iframe, iframeSourceObserverOptions); + } + var src = iframe.src; + if ( src === '' || typeof src !== 'string' ) { return; } + if ( src.startsWith('http') === false ) { return; } + toFilter.push({ type: 'frame', url: iframe.src }); + add(iframe); + }; + + var addIFrames = function(iframes) { + var i = iframes.length; + while ( i-- ) { + addIFrame(iframes[i]); + } + }; + + var addNodeList = function(nodeList) { + var node, + i = nodeList.length; + while ( i-- ) { + node = nodeList[i]; + if ( node.nodeType !== 1 ) { continue; } + if ( node.localName === 'iframe' ) { + addIFrame(node); + } + if ( node.childElementCount !== 0 ) { + addIFrames(node.querySelectorAll('iframe')); + } + } + }; + + var onResourceFailed = function(ev) { + if ( tagToTypeMap[ev.target.localName] !== undefined ) { + add(ev.target); + process(); + } + }; + document.addEventListener('error', onResourceFailed, true); + + vAPI.shutdown.add(function() { + document.removeEventListener('error', onResourceFailed, true); + if ( iframeSourceObserver !== undefined ) { + iframeSourceObserver.disconnect(); + iframeSourceObserver = undefined; + } + if ( processTimer !== undefined ) { + clearTimeout(processTimer); + processTimer = undefined; + } + }); + + return { + addMany: addMany, + addIFrames: addIFrames, + addNodeList: addNodeList, + process: process + }; +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// Observe changes in the DOM + +// Added node lists will be cumulated here before being processed + +(function() { + // This fixes http://acid3.acidtests.org/ + if ( !document.body ) { return; } + + var addedNodeLists = []; + var addedNodeListsTimer; + + var treeMutationObservedHandler = function() { + addedNodeListsTimer = undefined; + var i = addedNodeLists.length; + while ( i-- ) { + collapser.addNodeList(addedNodeLists[i]); + } + collapser.process(); + addedNodeLists = []; + }; + + // https://github.com/gorhill/uBlock/issues/205 + // Do not handle added node directly from within mutation observer. + var treeMutationObservedHandlerAsync = function(mutations) { + var iMutation = mutations.length, + nodeList; + while ( iMutation-- ) { + nodeList = mutations[iMutation].addedNodes; + if ( nodeList.length !== 0 ) { + addedNodeLists.push(nodeList); + } + } + if ( addedNodeListsTimer === undefined ) { + addedNodeListsTimer = vAPI.setTimeout(treeMutationObservedHandler, 47); + } + }; + + // https://github.com/gorhill/httpswitchboard/issues/176 + var treeObserver = new MutationObserver(treeMutationObservedHandlerAsync); + treeObserver.observe(document.body, { + childList: true, + subtree: true + }); + + vAPI.shutdown.add(function() { + if ( addedNodeListsTimer !== undefined ) { + clearTimeout(addedNodeListsTimer); + addedNodeListsTimer = undefined; + } + if ( treeObserver !== null ) { + treeObserver.disconnect(); + treeObserver = undefined; + } + addedNodeLists = []; + }); +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// Executed only once. +// +// https://github.com/gorhill/httpswitchboard/issues/25 +// +// https://github.com/gorhill/httpswitchboard/issues/131 +// Looks for inline javascript also in at least one a[href] element. +// +// https://github.com/gorhill/uMatrix/issues/485 +// Mind "on..." attributes. +// +// https://github.com/gorhill/uMatrix/issues/924 +// Report inline styles. + +(function() { + if ( + document.querySelector('script:not([src])') !== null || + document.querySelector('a[href^="javascript:"]') !== null || + document.querySelector('[onabort],[onblur],[oncancel],[oncanplay],[oncanplaythrough],[onchange],[onclick],[onclose],[oncontextmenu],[oncuechange],[ondblclick],[ondrag],[ondragend],[ondragenter],[ondragexit],[ondragleave],[ondragover],[ondragstart],[ondrop],[ondurationchange],[onemptied],[onended],[onerror],[onfocus],[oninput],[oninvalid],[onkeydown],[onkeypress],[onkeyup],[onload],[onloadeddata],[onloadedmetadata],[onloadstart],[onmousedown],[onmouseenter],[onmouseleave],[onmousemove],[onmouseout],[onmouseover],[onmouseup],[onwheel],[onpause],[onplay],[onplaying],[onprogress],[onratechange],[onreset],[onresize],[onscroll],[onseeked],[onseeking],[onselect],[onshow],[onstalled],[onsubmit],[onsuspend],[ontimeupdate],[ontoggle],[onvolumechange],[onwaiting],[onafterprint],[onbeforeprint],[onbeforeunload],[onhashchange],[onlanguagechange],[onmessage],[onoffline],[ononline],[onpagehide],[onpageshow],[onrejectionhandled],[onpopstate],[onstorage],[onunhandledrejection],[onunload],[oncopy],[oncut],[onpaste]') !== null + ) { + vAPI.messaging.send('contentscript.js', { + what: 'securityPolicyViolation', + directive: 'script-src', + documentURI: window.location.href + }); + } + + if ( document.querySelector('style,[style]') !== null ) { + vAPI.messaging.send('contentscript.js', { + what: 'securityPolicyViolation', + directive: 'style-src', + documentURI: window.location.href + }); + } + + collapser.addMany(document.querySelectorAll('img')); + collapser.addIFrames(document.querySelectorAll('iframe')); + collapser.process(); +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// Executed only once. + +// https://github.com/gorhill/uMatrix/issues/232 +// Force `display` property, Firefox is still affected by the issue. + +(function() { + var noscripts = document.querySelectorAll('noscript'); + if ( noscripts.length === 0 ) { return; } + + var redirectTimer, + reMetaContent = /^\s*(\d+)\s*;\s*url=(['"]?)([^'"]+)\2/i, + reSafeURL = /^https?:\/\//; + + var autoRefresh = function(root) { + var meta = root.querySelector('meta[http-equiv="refresh"][content]'); + if ( meta === null ) { return; } + var match = reMetaContent.exec(meta.getAttribute('content')); + if ( match === null || match[3].trim() === '' ) { return; } + var url = new URL(match[3], document.baseURI); + if ( reSafeURL.test(url.href) === false ) { return; } + redirectTimer = setTimeout( + function() { + location.assign(url.href); + }, + parseInt(match[1], 10) * 1000 + 1 + ); + meta.parentNode.removeChild(meta); + }; + + var morphNoscript = function(from) { + if ( /^application\/(?:xhtml\+)?xml/.test(document.contentType) ) { + var to = document.createElement('span'); + while ( from.firstChild !== null ) { + to.appendChild(from.firstChild); + } + return to; + } + var parser = new DOMParser(); + var doc = parser.parseFromString( + '<span>' + from.textContent + '</span>', + 'text/html' + ); + return document.adoptNode(doc.querySelector('span')); + }; + + var renderNoscriptTags = function(response) { + if ( response !== true ) { return; } + var parent, span; + for ( var noscript of noscripts ) { + parent = noscript.parentNode; + if ( parent === null ) { continue; } + span = morphNoscript(noscript); + span.style.setProperty('display', 'inline', 'important'); + if ( redirectTimer === undefined ) { + autoRefresh(span); + } + parent.replaceChild(span, noscript); + } + }; + + vAPI.messaging.send( + 'contentscript.js', + { what: 'mustRenderNoscriptTags?' }, + renderNoscriptTags + ); +})(); + +/******************************************************************************/ +/******************************************************************************/ + +vAPI.messaging.send( + 'contentscript.js', + { what: 'shutdown?' }, + function(response) { + if ( response === true ) { + vAPI.shutdown.exec(); + } + } +); + +/******************************************************************************/ +/******************************************************************************/ + +})(); diff --git a/js/cookies.js b/js/cookies.js new file mode 100644 index 0000000..7626ad0 --- /dev/null +++ b/js/cookies.js @@ -0,0 +1,552 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2013-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +// rhill 2013-12-14: the whole cookie management has been rewritten so as +// to avoid having to call chrome API whenever a single cookie changes, and +// to record cookie for a web page *only* when its value changes. +// https://github.com/gorhill/httpswitchboard/issues/79 + +"use strict"; + +/******************************************************************************/ + +// Isolate from global namespace + +// Use cached-context approach rather than object-based approach, as details +// of the implementation do not need to be visible + +µMatrix.cookieHunter = (function() { + +/******************************************************************************/ + +var µm = µMatrix; + +var recordPageCookiesQueue = new Map(); +var removePageCookiesQueue = new Map(); +var removeCookieQueue = new Set(); +var cookieDict = new Map(); +var cookieEntryJunkyard = []; +var processRemoveQueuePeriod = 2 * 60 * 1000; +var processCleanPeriod = 10 * 60 * 1000; +var processPageRecordQueueTimer = null; +var processPageRemoveQueueTimer = null; + +/******************************************************************************/ + +var CookieEntry = function(cookie) { + this.usedOn = new Set(); + this.init(cookie); +}; + +CookieEntry.prototype.init = function(cookie) { + this.secure = cookie.secure; + this.session = cookie.session; + this.anySubdomain = cookie.domain.charAt(0) === '.'; + this.hostname = this.anySubdomain ? cookie.domain.slice(1) : cookie.domain; + this.domain = µm.URI.domainFromHostname(this.hostname) || this.hostname; + this.path = cookie.path; + this.name = cookie.name; + this.value = cookie.value; + this.tstamp = Date.now(); + this.usedOn.clear(); + return this; +}; + +// Release anything which may consume too much memory + +CookieEntry.prototype.dispose = function() { + this.hostname = ''; + this.domain = ''; + this.path = ''; + this.name = ''; + this.value = ''; + this.usedOn.clear(); + return this; +}; + +/******************************************************************************/ + +var addCookieToDict = function(cookie) { + var cookieKey = cookieKeyFromCookie(cookie), + cookieEntry = cookieDict.get(cookieKey); + if ( cookieEntry === undefined ) { + cookieEntry = cookieEntryJunkyard.pop(); + if ( cookieEntry ) { + cookieEntry.init(cookie); + } else { + cookieEntry = new CookieEntry(cookie); + } + cookieDict.set(cookieKey, cookieEntry); + } + return cookieEntry; +}; + +/******************************************************************************/ + +var addCookiesToDict = function(cookies) { + var i = cookies.length; + while ( i-- ) { + addCookieToDict(cookies[i]); + } +}; + +/******************************************************************************/ + +var removeCookieFromDict = function(cookieKey) { + var cookieEntry = cookieDict.get(cookieKey); + if ( cookieEntry === undefined ) { return false; } + cookieDict.delete(cookieKey); + if ( cookieEntryJunkyard.length < 25 ) { + cookieEntryJunkyard.push(cookieEntry.dispose()); + } + return true; +}; + +/******************************************************************************/ + +var cookieKeyBuilder = [ + '', // 0 = scheme + '://', + '', // 2 = domain + '', // 3 = path + '{', + '', // 5 = persistent or session + '-cookie:', + '', // 7 = name + '}' +]; + +var cookieKeyFromCookie = function(cookie) { + var cb = cookieKeyBuilder; + cb[0] = cookie.secure ? 'https' : 'http'; + cb[2] = cookie.domain.charAt(0) === '.' ? cookie.domain.slice(1) : cookie.domain; + cb[3] = cookie.path; + cb[5] = cookie.session ? 'session' : 'persistent'; + cb[7] = cookie.name; + return cb.join(''); +}; + +var cookieKeyFromCookieURL = function(url, type, name) { + var µmuri = µm.URI.set(url); + var cb = cookieKeyBuilder; + cb[0] = µmuri.scheme; + cb[2] = µmuri.hostname; + cb[3] = µmuri.path; + cb[5] = type; + cb[7] = name; + return cb.join(''); +}; + +/******************************************************************************/ + +var cookieURLFromCookieEntry = function(entry) { + if ( !entry ) { + return ''; + } + return (entry.secure ? 'https://' : 'http://') + entry.hostname + entry.path; +}; + +/******************************************************************************/ + +var cookieMatchDomains = function(cookieKey, allHostnamesString) { + var cookieEntry = cookieDict.get(cookieKey); + if ( cookieEntry === undefined ) { return false; } + if ( allHostnamesString.indexOf(' ' + cookieEntry.hostname + ' ') < 0 ) { + if ( !cookieEntry.anySubdomain ) { + return false; + } + if ( allHostnamesString.indexOf('.' + cookieEntry.hostname + ' ') < 0 ) { + return false; + } + } + return true; +}; + +/******************************************************************************/ + +// Look for cookies to record for a specific web page + +var recordPageCookiesAsync = function(pageStats) { + // Store the page stats objects so that it doesn't go away + // before we handle the job. + // rhill 2013-10-19: pageStats could be nil, for example, this can + // happens if a file:// ... makes an xmlHttpRequest + if ( !pageStats ) { + return; + } + recordPageCookiesQueue.set(pageStats.pageUrl, pageStats); + if ( processPageRecordQueueTimer === null ) { + processPageRecordQueueTimer = vAPI.setTimeout(processPageRecordQueue, 1000); + } +}; + +/******************************************************************************/ + +var cookieLogEntryBuilder = [ + '', + '{', + '', + '-cookie:', + '', + '}' +]; + +var recordPageCookie = function(pageStore, cookieKey) { + if ( vAPI.isBehindTheSceneTabId(pageStore.tabId) ) { return; } + + var cookieEntry = cookieDict.get(cookieKey); + var pageHostname = pageStore.pageHostname; + var block = µm.mustBlock(pageHostname, cookieEntry.hostname, 'cookie'); + + cookieLogEntryBuilder[0] = cookieURLFromCookieEntry(cookieEntry); + cookieLogEntryBuilder[2] = cookieEntry.session ? 'session' : 'persistent'; + cookieLogEntryBuilder[4] = encodeURIComponent(cookieEntry.name); + + var cookieURL = cookieLogEntryBuilder.join(''); + + // rhill 2013-11-20: + // https://github.com/gorhill/httpswitchboard/issues/60 + // Need to URL-encode cookie name + pageStore.recordRequest('cookie', cookieURL, block); + µm.logger.writeOne(pageStore.tabId, 'net', pageHostname, cookieURL, 'cookie', block); + + cookieEntry.usedOn.add(pageHostname); + + // rhill 2013-11-21: + // https://github.com/gorhill/httpswitchboard/issues/65 + // Leave alone cookies from behind-the-scene requests if + // behind-the-scene processing is disabled. + if ( !block ) { + return; + } + if ( !µm.userSettings.deleteCookies ) { + return; + } + removeCookieAsync(cookieKey); +}; + +/******************************************************************************/ + +// Look for cookies to potentially remove for a specific web page + +var removePageCookiesAsync = function(pageStats) { + // Hold onto pageStats objects so that it doesn't go away + // before we handle the job. + // rhill 2013-10-19: pageStats could be nil, for example, this can + // happens if a file:// ... makes an xmlHttpRequest + if ( !pageStats ) { + return; + } + removePageCookiesQueue.set(pageStats.pageUrl, pageStats); + if ( processPageRemoveQueueTimer === null ) { + processPageRemoveQueueTimer = vAPI.setTimeout(processPageRemoveQueue, 15 * 1000); + } +}; + +/******************************************************************************/ + +// Candidate for removal + +var removeCookieAsync = function(cookieKey) { + removeCookieQueue.add(cookieKey); +}; + +/******************************************************************************/ + +var chromeCookieRemove = function(cookieEntry, name) { + var url = cookieURLFromCookieEntry(cookieEntry); + if ( url === '' ) { + return; + } + var sessionCookieKey = cookieKeyFromCookieURL(url, 'session', name); + var persistCookieKey = cookieKeyFromCookieURL(url, 'persistent', name); + var callback = function(details) { + var success = !!details; + var template = success ? i18nCookieDeleteSuccess : i18nCookieDeleteFailure; + if ( removeCookieFromDict(sessionCookieKey) ) { + if ( success ) { + µm.cookieRemovedCounter += 1; + } + µm.logger.writeOne('', 'info', 'cookie', template.replace('{{value}}', sessionCookieKey)); + } + if ( removeCookieFromDict(persistCookieKey) ) { + if ( success ) { + µm.cookieRemovedCounter += 1; + } + µm.logger.writeOne('', 'info', 'cookie', template.replace('{{value}}', persistCookieKey)); + } + }; + + vAPI.cookies.remove({ url: url, name: name }, callback); +}; + +var i18nCookieDeleteSuccess = vAPI.i18n('loggerEntryCookieDeleted'); +var i18nCookieDeleteFailure = vAPI.i18n('loggerEntryDeleteCookieError'); + +/******************************************************************************/ + +var processPageRecordQueue = function() { + processPageRecordQueueTimer = null; + + for ( var pageStore of recordPageCookiesQueue.values() ) { + findAndRecordPageCookies(pageStore); + } + recordPageCookiesQueue.clear(); +}; + +/******************************************************************************/ + +var processPageRemoveQueue = function() { + processPageRemoveQueueTimer = null; + + for ( var pageStore of removePageCookiesQueue.values() ) { + findAndRemovePageCookies(pageStore); + } + removePageCookiesQueue.clear(); +}; + +/******************************************************************************/ + +// Effectively remove cookies. + +var processRemoveQueue = function() { + var userSettings = µm.userSettings; + var deleteCookies = userSettings.deleteCookies; + + // Session cookies which timestamp is *after* tstampObsolete will + // be left untouched + // https://github.com/gorhill/httpswitchboard/issues/257 + var tstampObsolete = userSettings.deleteUnusedSessionCookies ? + Date.now() - userSettings.deleteUnusedSessionCookiesAfter * 60 * 1000 : + 0; + + var srcHostnames; + var cookieEntry; + + for ( var cookieKey of removeCookieQueue ) { + // rhill 2014-05-12: Apparently this can happen. I have to + // investigate how (A session cookie has same name as a + // persistent cookie?) + cookieEntry = cookieDict.get(cookieKey); + if ( cookieEntry === undefined ) { continue; } + + // Delete obsolete session cookies: enabled. + if ( tstampObsolete !== 0 && cookieEntry.session ) { + if ( cookieEntry.tstamp < tstampObsolete ) { + chromeCookieRemove(cookieEntry, cookieEntry.name); + continue; + } + } + + // Delete all blocked cookies: disabled. + if ( deleteCookies === false ) { + continue; + } + + // Query scopes only if we are going to use them + if ( srcHostnames === undefined ) { + srcHostnames = µm.tMatrix.extractAllSourceHostnames(); + } + + // Ensure cookie is not allowed on ALL current web pages: It can + // happen that a cookie is blacklisted on one web page while + // being whitelisted on another (because of per-page permissions). + if ( canRemoveCookie(cookieKey, srcHostnames) ) { + chromeCookieRemove(cookieEntry, cookieEntry.name); + } + } + + removeCookieQueue.clear(); + + vAPI.setTimeout(processRemoveQueue, processRemoveQueuePeriod); +}; + +/******************************************************************************/ + +// Once in a while, we go ahead and clean everything that might have been +// left behind. + +// Remove only some of the cookies which are candidate for removal: who knows, +// maybe a user has 1000s of cookies sitting in his browser... + +var processClean = function() { + var us = µm.userSettings; + if ( us.deleteCookies || us.deleteUnusedSessionCookies ) { + var cookieKeys = Array.from(cookieDict.keys()), + len = cookieKeys.length, + step, offset, n; + if ( len > 25 ) { + step = len / 25; + offset = Math.floor(Math.random() * len); + n = 25; + } else { + step = 1; + offset = 0; + n = len; + } + var i = offset; + while ( n-- ) { + removeCookieAsync(cookieKeys[Math.floor(i % len)]); + i += step; + } + } + + vAPI.setTimeout(processClean, processCleanPeriod); +}; + +/******************************************************************************/ + +var findAndRecordPageCookies = function(pageStore) { + for ( var cookieKey of cookieDict.keys() ) { + if ( cookieMatchDomains(cookieKey, pageStore.allHostnamesString) ) { + recordPageCookie(pageStore, cookieKey); + } + } +}; + +/******************************************************************************/ + +var findAndRemovePageCookies = function(pageStore) { + for ( var cookieKey of cookieDict.keys() ) { + if ( cookieMatchDomains(cookieKey, pageStore.allHostnamesString) ) { + removeCookieAsync(cookieKey); + } + } +}; + +/******************************************************************************/ + +var canRemoveCookie = function(cookieKey, srcHostnames) { + var cookieEntry = cookieDict.get(cookieKey); + if ( cookieEntry === undefined ) { return false; } + + var cookieHostname = cookieEntry.hostname; + var srcHostname; + + for ( srcHostname of cookieEntry.usedOn ) { + if ( µm.mustAllow(srcHostname, cookieHostname, 'cookie') ) { + return false; + } + } + // Maybe there is a scope in which the cookie is 1st-party-allowed. + // For example, if I am logged in into `github.com`, I do not want to be + // logged out just because I did not yet open a `github.com` page after + // re-starting the browser. + srcHostname = cookieHostname; + var pos; + for (;;) { + if ( srcHostnames.has(srcHostname) ) { + if ( µm.mustAllow(srcHostname, cookieHostname, 'cookie') ) { + return false; + } + } + if ( srcHostname === cookieEntry.domain ) { + break; + } + pos = srcHostname.indexOf('.'); + if ( pos === -1 ) { + break; + } + srcHostname = srcHostname.slice(pos + 1); + } + return true; +}; + +/******************************************************************************/ + +// Listen to any change in cookieland, we will update page stats accordingly. + +vAPI.cookies.onChanged = function(cookie) { + // rhill 2013-12-11: If cookie value didn't change, no need to record. + // https://github.com/gorhill/httpswitchboard/issues/79 + var cookieKey = cookieKeyFromCookie(cookie); + var cookieEntry = cookieDict.get(cookieKey); + if ( cookieEntry === undefined ) { + cookieEntry = addCookieToDict(cookie); + } else { + cookieEntry.tstamp = Date.now(); + if ( cookie.value === cookieEntry.value ) { return; } + cookieEntry.value = cookie.value; + } + + // Go through all pages and update if needed, as one cookie can be used + // by many web pages, so they need to be recorded for all these pages. + var pageStores = µm.pageStores; + var pageStore; + for ( var tabId in pageStores ) { + if ( pageStores.hasOwnProperty(tabId) === false ) { + continue; + } + pageStore = pageStores[tabId]; + if ( !cookieMatchDomains(cookieKey, pageStore.allHostnamesString) ) { + continue; + } + recordPageCookie(pageStore, cookieKey); + } +}; + +/******************************************************************************/ + +// Listen to any change in cookieland, we will update page stats accordingly. + +vAPI.cookies.onRemoved = function(cookie) { + var cookieKey = cookieKeyFromCookie(cookie); + if ( removeCookieFromDict(cookieKey) ) { + µm.logger.writeOne('', 'info', 'cookie', i18nCookieDeleteSuccess.replace('{{value}}', cookieKey)); + } +}; + +/******************************************************************************/ + +// Listen to any change in cookieland, we will update page stats accordingly. + +vAPI.cookies.onAllRemoved = function() { + for ( var cookieKey of cookieDict.keys() ) { + if ( removeCookieFromDict(cookieKey) ) { + µm.logger.writeOne('', 'info', 'cookie', i18nCookieDeleteSuccess.replace('{{value}}', cookieKey)); + } + } +}; + +/******************************************************************************/ + +vAPI.cookies.getAll(addCookiesToDict); +vAPI.cookies.start(); + +vAPI.setTimeout(processRemoveQueue, processRemoveQueuePeriod); +vAPI.setTimeout(processClean, processCleanPeriod); + +/******************************************************************************/ + +// Expose only what is necessary + +return { + recordPageCookies: recordPageCookiesAsync, + removePageCookies: removePageCookiesAsync +}; + +/******************************************************************************/ + +})(); + +/******************************************************************************/ + diff --git a/js/dashboard-common.js b/js/dashboard-common.js new file mode 100644 index 0000000..eecd668 --- /dev/null +++ b/js/dashboard-common.js @@ -0,0 +1,41 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/******************************************************************************/ + +uDom.onLoad(function() { + +/******************************************************************************/ + +// Open links in the proper window +uDom('a').attr('target', '_blank'); +uDom('a[href*="dashboard.html"]').attr('target', '_parent'); +uDom('.whatisthis').on('click', function() { + uDom(this).parent() + .descendants('.whatisthis-expandable') + .toggleClass('whatisthis-expanded'); +}); + + +/******************************************************************************/ + +}); diff --git a/js/dashboard.js b/js/dashboard.js new file mode 100644 index 0000000..5ff6ebc --- /dev/null +++ b/js/dashboard.js @@ -0,0 +1,56 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global uDom */ + +'use strict'; + +/******************************************************************************/ + +(function() { + + var loadDashboardPanel = function(hash) { + var button = uDom(hash); + var url = button.attr('data-dashboard-panel-url'); + uDom('iframe').attr('src', url); + uDom('.tabButton').forEach(function(button){ + button.toggleClass( + 'selected', + button.attr('data-dashboard-panel-url') === url + ); + }); + }; + + var onTabClickHandler = function() { + loadDashboardPanel(window.location.hash); + }; + + uDom.onLoad(function() { + window.addEventListener('hashchange', onTabClickHandler); + var hash = window.location.hash; + if ( hash.length < 2 ) { + hash = '#settings'; + } + loadDashboardPanel(hash); + }); + +})(); diff --git a/js/hosts-files.js b/js/hosts-files.js new file mode 100644 index 0000000..a259240 --- /dev/null +++ b/js/hosts-files.js @@ -0,0 +1,391 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global uDom */ + +'use strict'; + +/******************************************************************************/ + +(function() { + +/******************************************************************************/ + +var listDetails = {}, + lastUpdateTemplateString = vAPI.i18n('hostsFilesLastUpdate'), + hostsFilesSettingsHash, + reValidExternalList = /[a-z-]+:\/\/\S*\/\S+/; + +/******************************************************************************/ + +vAPI.messaging.addListener(function onMessage(msg) { + switch ( msg.what ) { + case 'assetUpdated': + updateAssetStatus(msg); + break; + case 'assetsUpdated': + document.body.classList.remove('updating'); + break; + case 'loadHostsFilesCompleted': + renderHostsFiles(); + break; + default: + break; + } +}); + +/******************************************************************************/ + +var renderNumber = function(value) { + return value.toLocaleString(); +}; + +/******************************************************************************/ + +var renderHostsFiles = function(soft) { + var listEntryTemplate = uDom('#templates .listEntry'), + listStatsTemplate = vAPI.i18n('hostsFilesPerFileStats'), + renderElapsedTimeToString = vAPI.i18n.renderElapsedTimeToString, + reExternalHostFile = /^https?:/; + + // Assemble a pretty list name if possible + var listNameFromListKey = function(listKey) { + var list = listDetails.current[listKey] || listDetails.available[listKey]; + var listTitle = list ? list.title : ''; + if ( listTitle === '' ) { return listKey; } + return listTitle; + }; + + var liFromListEntry = function(listKey, li) { + var entry = listDetails.available[listKey], + elem; + if ( !li ) { + li = listEntryTemplate.clone().nodeAt(0); + } + if ( li.getAttribute('data-listkey') !== listKey ) { + li.setAttribute('data-listkey', listKey); + elem = li.querySelector('input[type="checkbox"]'); + elem.checked = entry.off !== true; + elem = li.querySelector('a:nth-of-type(1)'); + elem.setAttribute('href', 'asset-viewer.html?url=' + encodeURI(listKey)); + elem.setAttribute('type', 'text/html'); + elem.textContent = listNameFromListKey(listKey); + li.classList.remove('toRemove'); + if ( entry.supportName ) { + li.classList.add('support'); + elem = li.querySelector('a.support'); + elem.setAttribute('href', entry.supportURL); + elem.setAttribute('title', entry.supportName); + } else { + li.classList.remove('support'); + } + if ( entry.external ) { + li.classList.add('external'); + } else { + li.classList.remove('external'); + } + if ( entry.instructionURL ) { + li.classList.add('mustread'); + elem = li.querySelector('a.mustread'); + elem.setAttribute('href', entry.instructionURL); + } else { + li.classList.remove('mustread'); + } + } + // https://github.com/gorhill/uBlock/issues/1429 + if ( !soft ) { + elem = li.querySelector('input[type="checkbox"]'); + elem.checked = entry.off !== true; + } + elem = li.querySelector('span.counts'); + var text = ''; + if ( !isNaN(+entry.entryUsedCount) && !isNaN(+entry.entryCount) ) { + text = listStatsTemplate + .replace('{{used}}', renderNumber(entry.off ? 0 : entry.entryUsedCount)) + .replace('{{total}}', renderNumber(entry.entryCount)); + } + elem.textContent = text; + // https://github.com/chrisaljoudi/uBlock/issues/104 + var asset = listDetails.cache[listKey] || {}; + var remoteURL = asset.remoteURL; + li.classList.toggle( + 'unsecure', + typeof remoteURL === 'string' && remoteURL.lastIndexOf('http:', 0) === 0 + ); + li.classList.toggle('failed', asset.error !== undefined); + li.classList.toggle('obsolete', asset.obsolete === true); + li.classList.toggle('cached', asset.cached === true && asset.writeTime > 0); + if ( asset.cached ) { + li.querySelector('.status.cache').setAttribute( + 'title', + lastUpdateTemplateString.replace('{{ago}}', renderElapsedTimeToString(asset.writeTime)) + ); + } + li.classList.remove('discard'); + return li; + }; + + var onListsReceived = function(details) { + // Before all, set context vars + listDetails = details; + + // Incremental rendering: this will allow us to easily discard unused + // DOM list entries. + uDom('#lists .listEntry').addClass('discard'); + + var availableLists = details.available, + listKeys = Object.keys(details.available); + + // Sort works this way: + // - Send /^https?:/ items at the end (custom hosts file URL) + listKeys.sort(function(a, b) { + var ta = availableLists[a].title || a, + tb = availableLists[b].title || b; + if ( reExternalHostFile.test(ta) === reExternalHostFile.test(tb) ) { + return ta.localeCompare(tb); + } + return reExternalHostFile.test(tb) ? -1 : 1; + }); + + var ulList = document.querySelector('#lists'); + for ( var i = 0; i < listKeys.length; i++ ) { + var liEntry = liFromListEntry(listKeys[i], ulList.children[i]); + if ( liEntry.parentElement === null ) { + ulList.appendChild(liEntry); + } + } + + uDom('#lists .listEntry.discard').remove(); + uDom('#listsOfBlockedHostsPrompt').text( + vAPI.i18n('hostsFilesStats').replace( + '{{blockedHostnameCount}}', + renderNumber(details.blockedHostnameCount) + ) + ); + uDom('#autoUpdate').prop('checked', listDetails.autoUpdate === true); + + if ( !soft ) { + hostsFilesSettingsHash = hashFromCurrentFromSettings(); + } + renderWidgets(); + }; + + vAPI.messaging.send('hosts-files.js', { what: 'getLists' }, onListsReceived); +}; + +/******************************************************************************/ + +var renderWidgets = function() { + uDom('#buttonUpdate').toggleClass('disabled', document.querySelector('body:not(.updating) #lists .listEntry.obsolete > input[type="checkbox"]:checked') === null); + uDom('#buttonPurgeAll').toggleClass('disabled', document.querySelector('#lists .listEntry.cached') === null); + uDom('#buttonApply').toggleClass('disabled', hostsFilesSettingsHash === hashFromCurrentFromSettings()); +}; + +/******************************************************************************/ + +var updateAssetStatus = function(details) { + var li = document.querySelector('#lists .listEntry[data-listkey="' + details.key + '"]'); + if ( li === null ) { return; } + li.classList.toggle('failed', !!details.failed); + li.classList.toggle('obsolete', !details.cached); + li.classList.toggle('cached', !!details.cached); + if ( details.cached ) { + li.querySelector('.status.cache').setAttribute( + 'title', + lastUpdateTemplateString.replace( + '{{ago}}', + vAPI.i18n.renderElapsedTimeToString(Date.now()) + ) + ); + } + renderWidgets(); +}; + +/******************************************************************************* + + Compute a hash from all the settings affecting how filter lists are loaded + in memory. + +**/ + +var hashFromCurrentFromSettings = function() { + var hash = [], + listHash = [], + listEntries = document.querySelectorAll('#lists .listEntry[data-listkey]:not(.toRemove)'), + liEntry, + i = listEntries.length; + while ( i-- ) { + liEntry = listEntries[i]; + if ( liEntry.querySelector('input[type="checkbox"]:checked') !== null ) { + listHash.push(liEntry.getAttribute('data-listkey')); + } + } + hash.push( + listHash.sort().join(), + reValidExternalList.test(document.getElementById('externalHostsFiles').value), + document.querySelector('#lists .listEntry.toRemove') !== null + ); + return hash.join(); +}; + +/******************************************************************************/ + +var onHostsFilesSettingsChanged = function() { + renderWidgets(); +}; + +/******************************************************************************/ + +var onRemoveExternalHostsFile = function(ev) { + var liEntry = uDom(this).ancestors('[data-listkey]'), + listKey = liEntry.attr('data-listkey'); + if ( listKey ) { + liEntry.toggleClass('toRemove'); + renderWidgets(); + } + ev.preventDefault(); +}; + +/******************************************************************************/ + +var onPurgeClicked = function() { + var button = uDom(this), + liEntry = button.ancestors('[data-listkey]'), + listKey = liEntry.attr('data-listkey'); + if ( !listKey ) { return; } + + vAPI.messaging.send('hosts-files.js', { what: 'purgeCache', assetKey: listKey }); + liEntry.addClass('obsolete'); + liEntry.removeClass('cached'); + + if ( liEntry.descendants('input').first().prop('checked') ) { + renderWidgets(); + } +}; + +/******************************************************************************/ + +var selectHostsFiles = function(callback) { + // Hosts files to select + var toSelect = [], + liEntries = document.querySelectorAll('#lists .listEntry[data-listkey]:not(.toRemove)'), + i = liEntries.length, + liEntry; + while ( i-- ) { + liEntry = liEntries[i]; + if ( liEntry.querySelector('input[type="checkbox"]:checked') !== null ) { + toSelect.push(liEntry.getAttribute('data-listkey')); + } + } + + // External hosts files to remove + var toRemove = []; + liEntries = document.querySelectorAll('#lists .listEntry.toRemove[data-listkey]'); + i = liEntries.length; + while ( i-- ) { + toRemove.push(liEntries[i].getAttribute('data-listkey')); + } + + // External hosts files to import + var externalListsElem = document.getElementById('externalHostsFiles'), + toImport = externalListsElem.value.trim(); + externalListsElem.value = ''; + + vAPI.messaging.send( + 'hosts-files.js', + { + what: 'selectHostsFiles', + toSelect: toSelect, + toImport: toImport, + toRemove: toRemove + }, + callback + ); + + hostsFilesSettingsHash = hashFromCurrentFromSettings(); +}; + +/******************************************************************************/ + +var buttonApplyHandler = function() { + uDom('#buttonApply').removeClass('enabled'); + selectHostsFiles(function() { + vAPI.messaging.send('hosts-files.js', { what: 'reloadHostsFiles' }); + }); + renderWidgets(); +}; + +/******************************************************************************/ + +var buttonUpdateHandler = function() { + uDom('#buttonUpdate').removeClass('enabled'); + selectHostsFiles(function() { + document.body.classList.add('updating'); + vAPI.messaging.send('hosts-files.js', { what: 'forceUpdateAssets' }); + renderWidgets(); + }); + renderWidgets(); +}; + +/******************************************************************************/ + +var buttonPurgeAllHandler = function() { + uDom('#buttonPurgeAll').removeClass('enabled'); + vAPI.messaging.send( + 'hosts-files.js', + { what: 'purgeAllCaches' }, + function() { + renderHostsFiles(true); + } + ); +}; + +/******************************************************************************/ + +var autoUpdateCheckboxChanged = function() { + vAPI.messaging.send( + 'hosts-files.js', + { + what: 'userSettings', + name: 'autoUpdate', + value: this.checked + } + ); +}; + +/******************************************************************************/ + +uDom('#autoUpdate').on('change', autoUpdateCheckboxChanged); +uDom('#buttonApply').on('click', buttonApplyHandler); +uDom('#buttonUpdate').on('click', buttonUpdateHandler); +uDom('#buttonPurgeAll').on('click', buttonPurgeAllHandler); +uDom('#lists').on('change', '.listEntry > input', onHostsFilesSettingsChanged); +uDom('#lists').on('click', '.listEntry > a.remove', onRemoveExternalHostsFile); +uDom('#lists').on('click', 'span.cache', onPurgeClicked); +uDom('#externalHostsFiles').on('input', onHostsFilesSettingsChanged); + +renderHostsFiles(); + +/******************************************************************************/ + +})(); + diff --git a/js/httpsb.js b/js/httpsb.js new file mode 100644 index 0000000..d76371a --- /dev/null +++ b/js/httpsb.js @@ -0,0 +1,212 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global chrome, µMatrix */ + +'use strict'; + +/******************************************************************************/ + +(function() { + var µm = µMatrix; + µm.pMatrix = new µm.Matrix(); + µm.pMatrix.setSwitch('matrix-off', 'about-scheme', 1); + µm.pMatrix.setSwitch('matrix-off', 'chrome-extension-scheme', 1); + µm.pMatrix.setSwitch('matrix-off', 'chrome-scheme', 1); + µm.pMatrix.setSwitch('matrix-off', 'moz-extension-scheme', 1); + µm.pMatrix.setSwitch('matrix-off', 'opera-scheme', 1); + // https://discourse.mozilla.org/t/support-umatrix/5131/157 + µm.pMatrix.setSwitch('matrix-off', 'wyciwyg-scheme', 1); + µm.pMatrix.setSwitch('matrix-off', 'behind-the-scene', 1); + µm.pMatrix.setSwitch('referrer-spoof', 'behind-the-scene', 2); + µm.pMatrix.setSwitch('https-strict', 'behind-the-scene', 2); + // Global rules + µm.pMatrix.setSwitch('referrer-spoof', '*', 1); + µm.pMatrix.setSwitch('noscript-spoof', '*', 1); + µm.pMatrix.setCell('*', '*', '*', µm.Matrix.Red); + µm.pMatrix.setCell('*', '*', 'css', µm.Matrix.Green); + µm.pMatrix.setCell('*', '*', 'image', µm.Matrix.Green); + µm.pMatrix.setCell('*', '*', 'frame', µm.Matrix.Red); + // 1st-party rules + µm.pMatrix.setCell('*', '1st-party', '*', µm.Matrix.Green); + µm.pMatrix.setCell('*', '1st-party', 'frame', µm.Matrix.Green); + + µm.tMatrix = new µm.Matrix(); + µm.tMatrix.assign(µm.pMatrix); +})(); + +/******************************************************************************/ + +µMatrix.hostnameFromURL = function(url) { + var hn = this.URI.hostnameFromURI(url); + return hn === '' ? '*' : hn; +}; + +µMatrix.scopeFromURL = µMatrix.hostnameFromURL; + +/******************************************************************************/ + +µMatrix.evaluateURL = function(srcURL, desHostname, type) { + var srcHostname = this.URI.hostnameFromURI(srcURL); + return this.tMatrix.evaluateCellZXY(srcHostname, desHostname, type); +}; + + +/******************************************************************************/ + +// Whitelist something + +µMatrix.whitelistTemporarily = function(srcHostname, desHostname, type) { + this.tMatrix.whitelistCell(srcHostname, desHostname, type); +}; + +µMatrix.whitelistPermanently = function(srcHostname, desHostname, type) { + if ( this.pMatrix.whitelistCell(srcHostname, desHostname, type) ) { + this.saveMatrix(); + } +}; + +/******************************************************************************/ + +// Auto-whitelisting the `all` cell is a serious action, hence this will be +// done only from within a scope. + +µMatrix.autoWhitelistAllTemporarily = function(pageURL) { + var srcHostname = this.URI.hostnameFromURI(pageURL); + if ( this.mustBlock(srcHostname, '*', '*') === false ) { + return false; + } + this.tMatrix.whitelistCell(srcHostname, '*', '*'); + return true; +}; + +/******************************************************************************/ + +// Blacklist something + +µMatrix.blacklistTemporarily = function(srcHostname, desHostname, type) { + this.tMatrix.blacklistCell(srcHostname, desHostname, type); +}; + +µMatrix.blacklistPermanently = function(srcHostname, desHostname, type) { + if ( this.pMatrix.blacklist(srcHostname, desHostname, type) ) { + this.saveMatrix(); + } +}; + +/******************************************************************************/ + +// Remove something from both black and white lists. + +µMatrix.graylistTemporarily = function(srcHostname, desHostname, type) { + this.tMatrix.graylistCell(srcHostname, desHostname, type); +}; + +µMatrix.graylistPermanently = function(srcHostname, desHostname, type) { + if ( this.pMatrix.graylistCell(srcHostname, desHostname, type) ) { + this.saveMatrix(); + } +}; + +/******************************************************************************/ + +// TODO: Should type be transposed by the caller or in place here? Not an +// issue at this point but to keep in mind as this function is called +// more and more from different places. + +µMatrix.filterRequest = function(fromURL, type, toURL) { + // Block request? + var srcHostname = this.hostnameFromURL(fromURL); + var desHostname = this.hostnameFromURL(toURL); + + // If no valid hostname, use the hostname of the source. + // For example, this case can happen with data URI. + if ( desHostname === '' ) { + desHostname = srcHostname; + } + + // Blocked by matrix filtering? + return this.mustBlock(srcHostname, desHostname, type); +}; + +/******************************************************************************/ + +µMatrix.mustBlock = function(srcHostname, desHostname, type) { + return this.tMatrix.mustBlock(srcHostname, desHostname, type); +}; + +µMatrix.mustAllow = function(srcHostname, desHostname, type) { + return this.mustBlock(srcHostname, desHostname, type) === false; +}; + +/******************************************************************************/ + +// Commit temporary permissions. + +µMatrix.commitPermissions = function(persist) { + this.pMatrix.assign(this.tMatrix); + if ( persist ) { + this.saveMatrix(); + } +}; + +/******************************************************************************/ + +// Reset all rules to their default state. + +µMatrix.revertAllRules = function() { + this.tMatrix.assign(this.pMatrix); +}; + +/******************************************************************************/ + +µMatrix.turnOff = function() { + vAPI.app.start(); +}; + +µMatrix.turnOn = function() { + vAPI.app.stop(); +}; + +/******************************************************************************/ + +µMatrix.formatCount = function(count) { + if ( typeof count !== 'number' ) { + return ''; + } + var s = count.toFixed(0); + if ( count >= 1000 ) { + if ( count < 10000 ) { + s = '>' + s.slice(0,1) + 'K'; + } else if ( count < 100000 ) { + s = s.slice(0,2) + 'K'; + } else if ( count < 1000000 ) { + s = s.slice(0,3) + 'K'; + } else if ( count < 10000000 ) { + s = s.slice(0,1) + 'M'; + } else { + s = s.slice(0,-6) + 'M'; + } + } + return s; +}; + diff --git a/js/i18n.js b/js/i18n.js new file mode 100644 index 0000000..5bb854c --- /dev/null +++ b/js/i18n.js @@ -0,0 +1,209 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global vAPI, uDom */ + +/******************************************************************************/ + +// This file should always be included at the end of the `body` tag, so as +// to ensure all i18n targets are already loaded. + +(function() { + +'use strict'; + +/******************************************************************************/ + +// https://github.com/gorhill/uBlock/issues/2084 +// Anything else than <a>, <b>, <code>, <em>, <i>, <input>, and <span> will +// be rendered as plain text. +// For <input>, only the type attribute is allowed. +// For <a>, only href attribute must be present, and it MUST starts with +// `https://`, and includes no single- or double-quotes. +// No HTML entities are allowed, there is code to handle existing HTML +// entities already present in translation files until they are all gone. + +var reSafeTags = /^([\s\S]*?)<(b|blockquote|code|em|i|kbd|span|sup)>(.+?)<\/\2>([\s\S]*)$/, + reSafeInput = /^([\s\S]*?)<(input type="[^"]+")>(.*?)([\s\S]*)$/, + reInput = /^input type=(['"])([a-z]+)\1$/, + reSafeLink = /^([\s\S]*?)<(a href=['"]https?:\/\/[^'" <>]+['"])>(.+?)<\/a>([\s\S]*)$/, + reLink = /^a href=(['"])(https?:\/\/[^'"]+)\1$/; + +var safeTextToTagNode = function(text) { + var matches, node; + if ( text.lastIndexOf('a ', 0) === 0 ) { + matches = reLink.exec(text); + if ( matches === null ) { return null; } + node = document.createElement('a'); + node.setAttribute('href', matches[2]); + return node; + } + if ( text.lastIndexOf('input ', 0) === 0 ) { + matches = reInput.exec(text); + if ( matches === null ) { return null; } + node = document.createElement('input'); + node.setAttribute('type', matches[2]); + return node; + } + // Firefox extension validator warns if using a variable as argument for + // document.createElement(). + switch ( text ) { + case 'b': + return document.createElement('b'); + case 'blockquote': + return document.createElement('blockquote'); + case 'code': + return document.createElement('code'); + case 'em': + return document.createElement('em'); + case 'i': + return document.createElement('i'); + case 'kbd': + return document.createElement('kbd'); + case 'span': + return document.createElement('span'); + case 'sup': + return document.createElement('sup'); + default: + break; + } +}; + +var safeTextToTextNode = function(text) { + // TODO: remove once no more HTML entities in translation files. + if ( text.indexOf('&') !== -1 ) { + text = text.replace(/“/g, '“') + .replace(/”/g, '”') + .replace(/‘/g, '‘') + .replace(/’/g, '’'); + } + return document.createTextNode(text); +}; + +var safeTextToDOM = function(text, parent) { + if ( text === '' ) { return; } + // Fast path (most common). + if ( text.indexOf('<') === -1 ) { + return parent.appendChild(safeTextToTextNode(text)); + } + // Slow path. + // `<p>` no longer allowed. Code below can be remove once all <p>'s are + // gone from translation files. + text = text.replace(/^<p>|<\/p>/g, '') + .replace(/<p>/g, '\n\n'); + // Parse allowed HTML tags. + var matches, + matches1 = reSafeTags.exec(text), + matches2 = reSafeLink.exec(text); + if ( matches1 !== null && matches2 !== null ) { + matches = matches1.index < matches2.index ? matches1 : matches2; + } else if ( matches1 !== null ) { + matches = matches1; + } else if ( matches2 !== null ) { + matches = matches2; + } else { + matches = reSafeInput.exec(text); + } + if ( matches === null ) { + parent.appendChild(safeTextToTextNode(text)); + return; + } + safeTextToDOM(matches[1], parent); + var node = safeTextToTagNode(matches[2]) || parent; + safeTextToDOM(matches[3], node); + parent.appendChild(node); + safeTextToDOM(matches[4], parent); +}; + +/******************************************************************************/ + +// Helper to deal with the i18n'ing of HTML files. +vAPI.i18n.render = function(context) { + var docu = document, + root = context || docu, + elems, n, i, elem, text; + + elems = root.querySelectorAll('[data-i18n]'); + n = elems.length; + for ( i = 0; i < n; i++ ) { + elem = elems[i]; + text = vAPI.i18n(elem.getAttribute('data-i18n')); + if ( !text ) { continue; } + // TODO: remove once it's all replaced with <input type="..."> + if ( text.indexOf('{') !== -1 ) { + text = text.replace(/\{\{input:([^}]+)\}\}/g, '<input type="$1">'); + } + safeTextToDOM(text, elem); + } + + uDom('[title]', context).forEach(function(elem) { + var title = vAPI.i18n(elem.attr('title')); + if ( title ) { + elem.attr('title', title); + } + }); + + uDom('[placeholder]', context).forEach(function(elem) { + elem.attr('placeholder', vAPI.i18n(elem.attr('placeholder'))); + }); + + uDom('[data-i18n-tip]', context).forEach(function(elem) { + elem.attr( + 'data-tip', + vAPI.i18n(elem.attr('data-i18n-tip')) + .replace(/<br>/g, '\n') + .replace(/\n{3,}/g, '\n\n') + ); + }); +}; + +vAPI.i18n.render(); + +/******************************************************************************/ + +vAPI.i18n.renderElapsedTimeToString = function(tstamp) { + var value = (Date.now() - tstamp) / 60000; + if ( value < 2 ) { + return vAPI.i18n('elapsedOneMinuteAgo'); + } + if ( value < 60 ) { + return vAPI.i18n('elapsedManyMinutesAgo').replace('{{value}}', Math.floor(value).toLocaleString()); + } + value /= 60; + if ( value < 2 ) { + return vAPI.i18n('elapsedOneHourAgo'); + } + if ( value < 24 ) { + return vAPI.i18n('elapsedManyHoursAgo').replace('{{value}}', Math.floor(value).toLocaleString()); + } + value /= 24; + if ( value < 2 ) { + return vAPI.i18n('elapsedOneDayAgo'); + } + return vAPI.i18n('elapsedManyDaysAgo').replace('{{value}}', Math.floor(value).toLocaleString()); +}; + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/js/liquid-dict.js b/js/liquid-dict.js new file mode 100644 index 0000000..92ca58c --- /dev/null +++ b/js/liquid-dict.js @@ -0,0 +1,203 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/******************************************************************************/ + +µMatrix.LiquidDict = (function() { + +/******************************************************************************/ + +var LiquidDict = function() { + this.dict = {}; + this.count = 0; + this.duplicateCount = 0; + this.bucketCount = 0; + this.frozenBucketCount = 0; + + // Somewhat arbitrary: I need to come up with hard data to know at which + // point binary search is better than indexOf. + this.cutoff = 500; +}; + +/******************************************************************************/ + +var meltBucket = function(ldict, len, bucket) { + ldict.frozenBucketCount -= 1; + var map = {}; + if ( bucket.charAt(0) === ' ' ) { + bucket.trim().split(' ').map(function(k) { + map[k] = true; + }); + } else { + var offset = 0; + while ( offset < bucket.length ) { + map[bucket.substring(offset, len)] = true; + offset += len; + } + } + return map; +}; + +/******************************************************************************/ + +var melt = function(ldict) { + var buckets = ldict.dict; + var bucket; + for ( var key in buckets ) { + bucket = buckets[key]; + if ( typeof bucket === 'string' ) { + buckets[key] = meltBucket(ldict, key.charCodeAt(0) & 0xFF, bucket); + } + } +}; + +/******************************************************************************/ + +var freezeBucket = function(ldict, bucket) { + ldict.frozenBucketCount += 1; + var words = Object.keys(bucket); + var wordLen = words[0].length; + if ( wordLen * words.length < ldict.cutoff ) { + return ' ' + words.join(' ') + ' '; + } + return words.sort().join(''); +}; + +/******************************************************************************/ + +// How the key is derived dictates the number and size of buckets. +// +// http://jsperf.com/makekey-concat-vs-join/3 +// +// Question: Why is using a prototyped function better than a standalone +// helper function? + +LiquidDict.prototype.makeKey = function(word) { + var len = word.length; + if ( len > 255 ) { + len = 255; + } + var i = len >> 2; + return String.fromCharCode( + (word.charCodeAt( 0) & 0x03) << 14 | + (word.charCodeAt( i) & 0x03) << 12 | + (word.charCodeAt( i+i) & 0x03) << 10 | + (word.charCodeAt(i+i+i) & 0x03) << 8 | + len + ); +}; + +/******************************************************************************/ + +LiquidDict.prototype.test = function(word) { + var key = this.makeKey(word); + var bucket = this.dict[key]; + if ( bucket === undefined ) { + return false; + } + if ( typeof bucket === 'object' ) { + return bucket[word] !== undefined; + } + if ( bucket.charAt(0) === ' ' ) { + return bucket.indexOf(' ' + word + ' ') >= 0; + } + // binary search + var len = word.length; + var left = 0; + // http://jsperf.com/or-vs-floor/3 + var right = ~~(bucket.length / len + 0.5); + var i, needle; + while ( left < right ) { + i = left + right >> 1; + needle = bucket.substr( len * i, len ); + if ( word < needle ) { + right = i; + } else if ( word > needle ) { + left = i + 1; + } else { + return true; + } + } + return false; +}; + +/******************************************************************************/ + +LiquidDict.prototype.add = function(word) { + var key = this.makeKey(word); + if ( key === undefined ) { + return false; + } + var bucket = this.dict[key]; + if ( bucket === undefined ) { + this.dict[key] = bucket = {}; + this.bucketCount += 1; + bucket[word] = true; + this.count += 1; + return true; + } else if ( typeof bucket === 'string' ) { + this.dict[key] = bucket = meltBucket(this, word.len, bucket); + } + if ( bucket[word] === undefined ) { + bucket[word] = true; + this.count += 1; + return true; + } + this.duplicateCount += 1; + return false; +}; + +/******************************************************************************/ + +LiquidDict.prototype.freeze = function() { + var buckets = this.dict; + var bucket; + for ( var key in buckets ) { + bucket = buckets[key]; + if ( typeof bucket === 'object' ) { + buckets[key] = freezeBucket(this, bucket); + } + } +}; + +/******************************************************************************/ + +LiquidDict.prototype.reset = function() { + this.dict = {}; + this.count = 0; + this.duplicateCount = 0; + this.bucketCount = 0; + this.frozenBucketCount = 0; +}; + +/******************************************************************************/ + +return LiquidDict; + +/******************************************************************************/ + +})(); + +/******************************************************************************/ + +µMatrix.ubiquitousBlacklist = new µMatrix.LiquidDict(); +µMatrix.ubiquitousWhitelist = new µMatrix.LiquidDict(); diff --git a/js/logger-ui.js b/js/logger-ui.js new file mode 100644 index 0000000..a3e9d9b --- /dev/null +++ b/js/logger-ui.js @@ -0,0 +1,908 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2015-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/sessbench +*/ + +/* global uDom */ + +'use strict'; + +/******************************************************************************/ + +(function() { + +/******************************************************************************/ + +var tbody = document.querySelector('#content tbody'); +var trJunkyard = []; +var tdJunkyard = []; +var firstVarDataCol = 2; // currently, column 2 (0-based index) +var lastVarDataIndex = 3; // currently, d0-d3 +var maxEntries = 0; +var noTabId = ''; +var allTabIds = {}; +var allTabIdsToken; +var ownerId = Date.now(); + +var emphasizeTemplate = document.querySelector('#emphasizeTemplate > span'); +var hiddenTemplate = document.querySelector('#hiddenTemplate > span'); + +var prettyRequestTypes = { + 'main_frame': 'doc', + 'stylesheet': 'css', + 'sub_frame': 'frame', + 'xmlhttprequest': 'xhr' +}; + +var dontEmphasizeSet = new Set([ + 'COOKIE', + 'CSP', + 'REFERER' +]); + +/******************************************************************************/ + +// Adjust top padding of content table, to match that of toolbar height. + +document.getElementById('content').style.setProperty( + 'margin-top', + document.getElementById('toolbar').clientHeight + 'px' +); + +/******************************************************************************/ + +var classNameFromTabId = function(tabId) { + if ( tabId === noTabId ) { + return 'tab_bts'; + } + if ( tabId !== '' ) { + return 'tab_' + tabId; + } + return ''; +}; + +/******************************************************************************/ + +// Emphasize hostname and cookie name. + +var emphasizeCookie = function(s) { + var pnode = emphasizeHostname(s); + if ( pnode.childNodes.length !== 3 ) { + return pnode; + } + var prefix = '-cookie:'; + var text = pnode.childNodes[2].textContent; + var beg = text.indexOf(prefix); + if ( beg === -1 ) { + return pnode; + } + beg += prefix.length; + var end = text.indexOf('}', beg); + if ( end === -1 ) { + return pnode; + } + var cnode = emphasizeTemplate.cloneNode(true); + cnode.childNodes[0].textContent = text.slice(0, beg); + cnode.childNodes[1].textContent = text.slice(beg, end); + cnode.childNodes[2].textContent = text.slice(end); + pnode.replaceChild(cnode.childNodes[0], pnode.childNodes[2]); + pnode.appendChild(cnode.childNodes[0]); + pnode.appendChild(cnode.childNodes[0]); + return pnode; +}; + +/******************************************************************************/ + +// Emphasize hostname in URL. + +var emphasizeHostname = function(url) { + var hnbeg = url.indexOf('://'); + if ( hnbeg === -1 ) { + return document.createTextNode(url); + } + hnbeg += 3; + + var hnend = url.indexOf('/', hnbeg); + if ( hnend === -1 ) { + hnend = url.slice(hnbeg).search(/\?#/); + if ( hnend !== -1 ) { + hnend += hnbeg; + } else { + hnend = url.length; + } + } + + var node = emphasizeTemplate.cloneNode(true); + node.childNodes[0].textContent = url.slice(0, hnbeg); + node.childNodes[1].textContent = url.slice(hnbeg, hnend); + node.childNodes[2].textContent = url.slice(hnend); + return node; +}; + +/******************************************************************************/ + +var createCellAt = function(tr, index) { + var td = tr.cells[index]; + var mustAppend = !td; + if ( mustAppend ) { + td = tdJunkyard.pop(); + } + if ( td ) { + td.removeAttribute('colspan'); + td.textContent = ''; + } else { + td = document.createElement('td'); + } + if ( mustAppend ) { + tr.appendChild(td); + } + return td; +}; + +/******************************************************************************/ + +var createRow = function(layout) { + var tr = trJunkyard.pop(); + if ( tr ) { + tr.className = ''; + } else { + tr = document.createElement('tr'); + } + for ( var index = 0; index < firstVarDataCol; index++ ) { + createCellAt(tr, index); + } + var i = 1, span = 1, td; + for (;;) { + td = createCellAt(tr, index); + if ( i === lastVarDataIndex ) { + break; + } + if ( layout.charAt(i) !== '1' ) { + span += 1; + } else { + if ( span !== 1 ) { + td.setAttribute('colspan', span); + } + index += 1; + span = 1; + } + i += 1; + } + if ( span !== 1 ) { + td.setAttribute('colspan', span); + } + index += 1; + while ( (td = tr.cells[index]) ) { + tdJunkyard.push(tr.removeChild(td)); + } + return tr; +}; + +/******************************************************************************/ + +var createHiddenTextNode = function(text) { + var node = hiddenTemplate.cloneNode(true); + node.textContent = text; + return node; +}; + +/******************************************************************************/ + +var padTo2 = function(v) { + return v < 10 ? '0' + v : v; +}; + +/******************************************************************************/ + +var createGap = function(tabId, url) { + var tr = createRow('1'); + tr.classList.add('doc'); + tr.classList.add('tab'); + tr.classList.add('canMtx'); + tr.classList.add('tab_' + tabId); + tr.cells[firstVarDataCol].textContent = url; + tbody.insertBefore(tr, tbody.firstChild); +}; + +/******************************************************************************/ + +var renderLogEntry = function(entry) { + var tr; + var fvdc = firstVarDataCol; + + switch ( entry.cat ) { + case 'error': + case 'info': + tr = createRow('1'); + if ( entry.d0 === 'cookie' ) { + tr.cells[fvdc].appendChild(emphasizeCookie(entry.d1)); + } else { + tr.cells[fvdc].textContent = entry.d0; + } + break; + + case 'net': + tr = createRow('111'); + tr.classList.add('canMtx'); + // If the request is that of a root frame, insert a gap in the table + // in order to visually separate entries for different documents. + if ( entry.d2 === 'doc' && entry.tab !== noTabId ) { + createGap(entry.tab, entry.d1); + } + if ( entry.d3 ) { + tr.classList.add('blocked'); + tr.cells[fvdc].textContent = '--'; + } else { + tr.cells[fvdc].textContent = ''; + } + tr.cells[fvdc+1].textContent = (prettyRequestTypes[entry.d2] || entry.d2); + if ( dontEmphasizeSet.has(entry.d2) ) { + tr.cells[fvdc+2].textContent = entry.d1; + } else if ( entry.d2 === 'cookie' ) { + tr.cells[fvdc+2].appendChild(emphasizeCookie(entry.d1)); + } else { + tr.cells[fvdc+2].appendChild(emphasizeHostname(entry.d1)); + } + break; + + default: + tr = createRow('1'); + tr.cells[fvdc].textContent = entry.d0; + break; + } + + // Fields common to all rows. + var time = logDate; + time.setTime(entry.tstamp - logDateTimezoneOffset); + tr.cells[0].textContent = padTo2(time.getUTCHours()) + ':' + + padTo2(time.getUTCMinutes()) + ':' + + padTo2(time.getSeconds()); + + if ( entry.tab ) { + tr.classList.add('tab'); + tr.classList.add(classNameFromTabId(entry.tab)); + if ( entry.tab === noTabId ) { + tr.cells[1].appendChild(createHiddenTextNode('bts')); + } + } + if ( entry.cat !== '' ) { + tr.classList.add('cat_' + entry.cat); + } + + rowFilterer.filterOne(tr, true); + + tbody.insertBefore(tr, tbody.firstChild); +}; + +// Reuse date objects. +var logDate = new Date(), + logDateTimezoneOffset = logDate.getTimezoneOffset() * 60000; + +/******************************************************************************/ + +var renderLogEntries = function(response) { + var entries = response.entries; + if ( entries.length === 0 ) { + return; + } + + // Preserve scroll position + var height = tbody.offsetHeight; + + var tabIds = response.tabIds; + var n = entries.length; + var entry; + for ( var i = 0; i < n; i++ ) { + entry = entries[i]; + // Unlikely, but it may happen + if ( entry.tab && tabIds.hasOwnProperty(entry.tab) === false ) { + continue; + } + renderLogEntry(entries[i]); + } + + // Prevent logger from growing infinitely and eating all memory. For + // instance someone could forget that it is left opened for some + // dynamically refreshed pages. + truncateLog(maxEntries); + + var yDelta = tbody.offsetHeight - height; + if ( yDelta === 0 ) { + return; + } + + // Chromium: + // body.scrollTop = good value + // body.parentNode.scrollTop = 0 + if ( document.body.scrollTop !== 0 ) { + document.body.scrollTop += yDelta; + return; + } + + // Firefox: + // body.scrollTop = 0 + // body.parentNode.scrollTop = good value + var parentNode = document.body.parentNode; + if ( parentNode && parentNode.scrollTop !== 0 ) { + parentNode.scrollTop += yDelta; + } +}; + +/******************************************************************************/ + +var synchronizeTabIds = function(newTabIds) { + var oldTabIds = allTabIds; + var autoDeleteVoidRows = !!vAPI.localStorage.getItem('loggerAutoDeleteVoidRows'); + var rowVoided = false; + var trs; + for ( var tabId in oldTabIds ) { + if ( oldTabIds.hasOwnProperty(tabId) === false ) { + continue; + } + if ( newTabIds.hasOwnProperty(tabId) ) { + continue; + } + // Mark or remove voided rows + trs = uDom('.tab_' + tabId); + if ( autoDeleteVoidRows ) { + toJunkyard(trs); + } else { + trs.removeClass('canMtx'); + rowVoided = true; + } + // Remove popup if it is currently bound to a removed tab. + if ( tabId === popupManager.tabId ) { + popupManager.toggleOff(); + } + } + + var select = document.getElementById('pageSelector'); + var selectValue = select.value; + var tabIds = Object.keys(newTabIds).sort(function(a, b) { + return newTabIds[a].localeCompare(newTabIds[b]); + }); + var option; + for ( var i = 0, j = 2; i < tabIds.length; i++ ) { + tabId = tabIds[i]; + if ( tabId === noTabId ) { + continue; + } + option = select.options[j]; + j += 1; + if ( !option ) { + option = document.createElement('option'); + select.appendChild(option); + } + option.textContent = newTabIds[tabId]; + option.value = classNameFromTabId(tabId); + if ( option.value === selectValue ) { + option.setAttribute('selected', ''); + } else { + option.removeAttribute('selected'); + } + } + while ( j < select.options.length ) { + select.removeChild(select.options[j]); + } + if ( select.value !== selectValue ) { + select.selectedIndex = 0; + select.value = ''; + select.options[0].setAttribute('selected', ''); + pageSelectorChanged(); + } + + allTabIds = newTabIds; + + return rowVoided; +}; + +/******************************************************************************/ + +var truncateLog = function(size) { + if ( size === 0 ) { + size = 5000; + } + var tbody = document.querySelector('#content tbody'); + size = Math.min(size, 10000); + var tr; + while ( tbody.childElementCount > size ) { + tr = tbody.lastElementChild; + trJunkyard.push(tbody.removeChild(tr)); + } +}; + +/******************************************************************************/ + +var onLogBufferRead = function(response) { + if ( !response || response.unavailable ) { + readLogBufferAsync(); + return; + } + + // This tells us the behind-the-scene tab id + noTabId = response.noTabId; + + // This may have changed meanwhile + if ( response.maxLoggedRequests !== maxEntries ) { + maxEntries = response.maxLoggedRequests; + uDom('#maxEntries').val(maxEntries || ''); + } + + // Neuter rows for which a tab does not exist anymore + var rowVoided = false; + if ( response.tabIdsToken !== allTabIdsToken ) { + rowVoided = synchronizeTabIds(response.tabIds); + allTabIdsToken = response.tabIdsToken; + } + + renderLogEntries(response); + + if ( rowVoided ) { + uDom('#clean').toggleClass( + 'disabled', + tbody.querySelector('tr.tab:not(.canMtx)') === null + ); + } + + // Synchronize toolbar with content of log + uDom('#clear').toggleClass( + 'disabled', + tbody.querySelector('tr') === null + ); + + readLogBufferAsync(); +}; + +/******************************************************************************/ + +// This can be called only once, at init time. After that, this will be called +// automatically. If called after init time, this will be messy, and this would +// require a bit more code to ensure no multi time out events. + +var readLogBuffer = function() { + if ( ownerId === undefined ) { return; } + vAPI.messaging.send( + 'logger-ui.js', + { what: 'readMany', ownerId: ownerId }, + onLogBufferRead + ); +}; + +var readLogBufferAsync = function() { + if ( ownerId === undefined ) { return; } + vAPI.setTimeout(readLogBuffer, 1200); +}; + +/******************************************************************************/ + +var pageSelectorChanged = function() { + var style = document.getElementById('tabFilterer'); + var tabClass = document.getElementById('pageSelector').value; + var sheet = style.sheet; + while ( sheet.cssRules.length !== 0 ) { + sheet.deleteRule(0); + } + if ( tabClass !== '' ) { + sheet.insertRule( + '#content table tr:not(.' + tabClass + ') { display: none; }', + 0 + ); + } + uDom('#refresh').toggleClass( + 'disabled', + tabClass === '' || tabClass === 'tab_bts' + ); +}; + +/******************************************************************************/ + +var refreshTab = function() { + var tabClass = document.getElementById('pageSelector').value; + var matches = tabClass.match(/^tab_(.+)$/); + if ( matches === null ) { + return; + } + if ( matches[1] === 'bts' ) { + return; + } + vAPI.messaging.send( + 'logger-ui.js', + { what: 'forceReloadTab', tabId: matches[1] } + ); +}; + +/******************************************************************************/ + +var onMaxEntriesChanged = function() { + var raw = uDom(this).val(); + try { + maxEntries = parseInt(raw, 10); + if ( isNaN(maxEntries) ) { + maxEntries = 0; + } + } catch (e) { + maxEntries = 0; + } + + vAPI.messaging.send('logger-ui.js', { + what: 'userSettings', + name: 'maxLoggedRequests', + value: maxEntries + }); + + truncateLog(maxEntries); +}; + +/******************************************************************************/ + +var rowFilterer = (function() { + var filters = []; + + var parseInput = function() { + filters = []; + + var rawPart, hardBeg, hardEnd; + var raw = uDom('#filterInput').val().trim(); + var rawParts = raw.split(/\s+/); + var reStr, reStrs = [], not = false; + var n = rawParts.length; + for ( var i = 0; i < n; i++ ) { + rawPart = rawParts[i]; + if ( rawPart.charAt(0) === '!' ) { + if ( reStrs.length === 0 ) { + not = true; + } + rawPart = rawPart.slice(1); + } + hardBeg = rawPart.charAt(0) === '|'; + if ( hardBeg ) { + rawPart = rawPart.slice(1); + } + hardEnd = rawPart.slice(-1) === '|'; + if ( hardEnd ) { + rawPart = rawPart.slice(0, -1); + } + if ( rawPart === '' ) { + continue; + } + // https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions + reStr = rawPart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if ( hardBeg ) { + reStr = '(?:^|\\s)' + reStr; + } + if ( hardEnd ) { + reStr += '(?:\\s|$)'; + } + reStrs.push(reStr); + if ( i < (n - 1) && rawParts[i + 1] === '||' ) { + i += 1; + continue; + } + reStr = reStrs.length === 1 ? reStrs[0] : reStrs.join('|'); + filters.push({ + re: new RegExp(reStr, 'i'), + r: !not + }); + reStrs = []; + not = false; + } + }; + + var filterOne = function(tr, clean) { + var ff = filters; + var fcount = ff.length; + if ( fcount === 0 && clean === true ) { + return; + } + // do not filter out doc boundaries, they help separate important + // section of log. + var cl = tr.classList; + if ( cl.contains('doc') ) { + return; + } + if ( fcount === 0 ) { + cl.remove('f'); + return; + } + var cc = tr.cells; + var ccount = cc.length; + var hit, j, f; + // each filter expression must hit (implicit and-op) + // if... + // positive filter expression = there must one hit on any field + // negative filter expression = there must be no hit on all fields + for ( var i = 0; i < fcount; i++ ) { + f = ff[i]; + hit = !f.r; + for ( j = 0; j < ccount; j++ ) { + if ( f.re.test(cc[j].textContent) ) { + hit = f.r; + break; + } + } + if ( !hit ) { + cl.add('f'); + return; + } + } + cl.remove('f'); + }; + + var filterAll = function() { + // Special case: no filter + if ( filters.length === 0 ) { + uDom('#content tr').removeClass('f'); + return; + } + var tbody = document.querySelector('#content tbody'); + var rows = tbody.rows; + var i = rows.length; + while ( i-- ) { + filterOne(rows[i]); + } + }; + + var onFilterChangedAsync = (function() { + var timer = null; + var commit = function() { + timer = null; + parseInput(); + filterAll(); + }; + return function() { + if ( timer !== null ) { + clearTimeout(timer); + } + timer = vAPI.setTimeout(commit, 750); + }; + })(); + + var onFilterButton = function() { + var cl = document.body.classList; + cl.toggle('f', cl.contains('f') === false); + }; + + uDom('#filterButton').on('click', onFilterButton); + uDom('#filterInput').on('input', onFilterChangedAsync); + + return { + filterOne: filterOne, + filterAll: filterAll + }; +})(); + +/******************************************************************************/ + +var toJunkyard = function(trs) { + trs.remove(); + var i = trs.length; + while ( i-- ) { + trJunkyard.push(trs.nodeAt(i)); + } +}; + +/******************************************************************************/ + +var clearBuffer = function() { + var tbody = document.querySelector('#content tbody'); + var tr; + while ( tbody.firstChild !== null ) { + tr = tbody.lastElementChild; + trJunkyard.push(tbody.removeChild(tr)); + } + uDom('#clear').addClass('disabled'); + uDom('#clean').addClass('disabled'); +}; + +/******************************************************************************/ + +var cleanBuffer = function() { + var rows = uDom('#content tr.tab:not(.canMtx)').remove(); + var i = rows.length; + while ( i-- ) { + trJunkyard.push(rows.nodeAt(i)); + } + uDom('#clean').addClass('disabled'); +}; + +/******************************************************************************/ + +var toggleCompactView = function() { + document.body.classList.toggle('compactView'); + uDom('#content table .vExpanded').removeClass('vExpanded'); +}; + +var toggleCompactRow = function(ev) { + ev.target.parentElement.classList.toggle('vExpanded'); +}; + +/******************************************************************************/ + +var popupManager = (function() { + var realTabId = null; + var localTabId = null; + var container = null; + var popup = null; + var popupObserver = null; + var style = null; + var styleTemplate = [ + 'tr:not(.tab_{{tabId}}) {', + 'cursor: not-allowed;', + 'opacity: 0.2;', + '}' + ].join('\n'); + + var resizePopup = function() { + if ( popup === null ) { + return; + } + var popupBody = popup.contentWindow.document.body; + if ( popupBody.clientWidth !== 0 && container.clientWidth !== popupBody.clientWidth ) { + container.style.setProperty('width', popupBody.clientWidth + 'px'); + } + popup.style.removeProperty('height'); + if ( popupBody.clientHeight !== 0 && popup.clientHeight !== popupBody.clientHeight ) { + popup.style.setProperty('height', popupBody.clientHeight + 'px'); + } + var ph = document.documentElement.clientHeight; + var crect = container.getBoundingClientRect(); + if ( crect.height > ph ) { + popup.style.setProperty('height', 'calc(' + ph + 'px - 1.8em)'); + } + // Adjust width for presence/absence of vertical scroll bar which may + // have appeared as a result of last operation. + var cw = container.clientWidth; + var dw = popup.contentWindow.document.documentElement.clientWidth; + if ( cw !== dw ) { + container.style.setProperty('width', (2 * cw - dw) + 'px'); + } + }; + + var toggleSize = function() { + container.classList.toggle('hide'); + }; + + var onResizeRequested = function() { + var popupBody = popup.contentWindow.document.body; + if ( popupBody.hasAttribute('data-resize-popup') === false ) { + return; + } + popupBody.removeAttribute('data-resize-popup'); + resizePopup(); + }; + + var onLoad = function() { + resizePopup(); + var popupBody = popup.contentDocument.body; + popupBody.removeAttribute('data-resize-popup'); + popupObserver.observe(popupBody, { + attributes: true, + attributesFilter: [ 'data-resize-popup' ] + }); + }; + + var toggleOn = function(td) { + var tr = td.parentNode; + var matches = tr.className.match(/(?:^| )tab_([^ ]+)/); + if ( matches === null ) { + return; + } + realTabId = localTabId = matches[1]; + if ( localTabId === 'bts' ) { + realTabId = noTabId; + } + + container = document.getElementById('popupContainer'); + + container.querySelector('div > span:nth-of-type(1)').addEventListener('click', toggleSize); + container.querySelector('div > span:nth-of-type(2)').addEventListener('click', toggleOff); + + popup = document.createElement('iframe'); + popup.addEventListener('load', onLoad); + popup.setAttribute('src', 'popup.html?tabId=' + realTabId); + popupObserver = new MutationObserver(onResizeRequested); + container.appendChild(popup); + + style = document.getElementById('popupFilterer'); + style.textContent = styleTemplate.replace('{{tabId}}', localTabId); + + document.body.classList.add('popupOn'); + }; + + var toggleOff = function() { + document.body.classList.remove('popupOn'); + + container.querySelector('div > span:nth-of-type(1)').removeEventListener('click', toggleSize); + container.querySelector('div > span:nth-of-type(2)').removeEventListener('click', toggleOff); + container.classList.remove('hide'); + + popup.removeEventListener('load', onLoad); + popupObserver.disconnect(); + popupObserver = null; + popup.setAttribute('src', ''); + container.removeChild(popup); + popup = null; + + style.textContent = ''; + style = null; + + container = null; + realTabId = null; + }; + + var exports = { + toggleOn: function(ev) { + if ( realTabId === null ) { + toggleOn(ev.target); + } + }, + toggleOff: function() { + if ( realTabId !== null ) { + toggleOff(); + } + } + }; + + Object.defineProperty(exports, 'tabId', { + get: function() { return realTabId || 0; } + }); + + return exports; +})(); + +/******************************************************************************/ + +var grabView = function() { + if ( ownerId === undefined ) { + ownerId = Date.now(); + } + readLogBufferAsync(); +}; + +var releaseView = function() { + if ( ownerId === undefined ) { return; } + vAPI.messaging.send( + 'logger-ui.js', + { what: 'releaseView', ownerId: ownerId } + ); + ownerId = undefined; +}; + +window.addEventListener('pagehide', releaseView); +window.addEventListener('pageshow', grabView); +// https://bugzilla.mozilla.org/show_bug.cgi?id=1398625 +window.addEventListener('beforeunload', releaseView); + +/******************************************************************************/ + +readLogBuffer(); + +uDom('#pageSelector').on('change', pageSelectorChanged); +uDom('#refresh').on('click', refreshTab); +uDom('#compactViewToggler').on('click', toggleCompactView); +uDom('#clean').on('click', cleanBuffer); +uDom('#clear').on('click', clearBuffer); +uDom('#maxEntries').on('change', onMaxEntriesChanged); +uDom('#content table').on('click', 'tr > td:nth-of-type(1)', toggleCompactRow); +uDom('#content table').on('click', 'tr.canMtx > td:nth-of-type(2)', popupManager.toggleOn); + +/******************************************************************************/ + +})(); diff --git a/js/logger.js b/js/logger.js new file mode 100644 index 0000000..896fbdf --- /dev/null +++ b/js/logger.js @@ -0,0 +1,93 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2015-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uBlock +*/ + +'use strict'; + +/******************************************************************************/ +/******************************************************************************/ + +µMatrix.logger = (function() { + + var LogEntry = function(args) { + this.init(args); + }; + + LogEntry.prototype.init = function(args) { + this.tstamp = Date.now(); + this.tab = args[0] || ''; + this.cat = args[1] || ''; + this.d0 = args[2]; + this.d1 = args[3]; + this.d2 = args[4]; + this.d3 = args[5]; + }; + + var buffer = null; + var lastReadTime = 0; + var writePtr = 0; + + // After 60 seconds without being read, a buffer will be considered + // unused, and thus removed from memory. + var logBufferObsoleteAfter = 30 * 1000; + + var janitor = function() { + if ( + buffer !== null && + lastReadTime < (Date.now() - logBufferObsoleteAfter) + ) { + buffer = null; + writePtr = 0; + api.ownerId = undefined; + } + if ( buffer !== null ) { + vAPI.setTimeout(janitor, logBufferObsoleteAfter); + } + }; + + var api = { + ownerId: undefined, + writeOne: function() { + if ( buffer === null ) { return; } + if ( writePtr === buffer.length ) { + buffer.push(new LogEntry(arguments)); + } else { + buffer[writePtr].init(arguments); + } + writePtr += 1; + }, + readAll: function(ownerId) { + this.ownerId = ownerId; + if ( buffer === null ) { + buffer = []; + vAPI.setTimeout(janitor, logBufferObsoleteAfter); + } + var out = buffer.slice(0, writePtr); + writePtr = 0; + lastReadTime = Date.now(); + return out; + } + }; + + return api; +})(); + +/******************************************************************************/ diff --git a/js/main-blocked.js b/js/main-blocked.js new file mode 100644 index 0000000..cb11910 --- /dev/null +++ b/js/main-blocked.js @@ -0,0 +1,176 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2015-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uBlock +*/ + +/* global uDom */ + +'use strict'; + +/******************************************************************************/ + +(function() { + +/******************************************************************************/ + +var details = {}; + +(function() { + var matches = /details=([^&]+)/.exec(window.location.search); + if ( matches === null ) { return; } + try { + details = JSON.parse(atob(matches[1])); + } catch(ex) { + } +})(); + +/******************************************************************************/ + +uDom('.what').text(details.url); +// uDom('#why').text(details.why.slice(3)); + +/******************************************************************************/ + +// https://github.com/gorhill/uMatrix/issues/502 +// Code below originally imported from: +// https://github.com/gorhill/uBlock/blob/master/src/js/document-blocked.js + +(function() { + if ( typeof URL !== 'function' ) { return; } + + var reURL = /^https?:\/\//; + + var liFromParam = function(name, value) { + if ( value === '' ) { + value = name; + name = ''; + } + var li = document.createElement('li'); + var span = document.createElement('span'); + span.textContent = name; + li.appendChild(span); + if ( name !== '' && value !== '' ) { + li.appendChild(document.createTextNode(' = ')); + } + span = document.createElement('span'); + if ( reURL.test(value) ) { + var a = document.createElement('a'); + a.href = a.textContent = value; + span.appendChild(a); + } else { + span.textContent = value; + } + li.appendChild(span); + return li; + }; + + var safeDecodeURIComponent = function(s) { + try { + s = decodeURIComponent(s); + } catch (ex) { + } + return s; + }; + + var renderParams = function(parentNode, rawURL) { + var a = document.createElement('a'); + a.href = rawURL; + if ( a.search.length === 0 ) { return false; } + + var pos = rawURL.indexOf('?'); + var li = liFromParam( + vAPI.i18n('docblockedNoParamsPrompt'), + rawURL.slice(0, pos) + ); + parentNode.appendChild(li); + + var params = a.search.slice(1).split('&'); + var param, name, value, ul; + for ( var i = 0; i < params.length; i++ ) { + param = params[i]; + pos = param.indexOf('='); + if ( pos === -1 ) { + pos = param.length; + } + name = safeDecodeURIComponent(param.slice(0, pos)); + value = safeDecodeURIComponent(param.slice(pos + 1)); + li = liFromParam(name, value); + if ( reURL.test(value) ) { + ul = document.createElement('ul'); + renderParams(ul, value); + li.appendChild(ul); + } + parentNode.appendChild(li); + } + return true; + }; + + if ( renderParams(uDom.nodeFromId('parsed'), details.url) === false ) { + return; + } + + var toggler = document.createElement('span'); + toggler.className = 'fa'; + uDom('#theURL > p').append(toggler); + + uDom(toggler).on('click', function() { + var collapsed = uDom.nodeFromId('theURL').classList.toggle('collapsed'); + vAPI.localStorage.setItem( + 'document-blocked-collapse-url', + collapsed.toString() + ); + }); + + uDom.nodeFromId('theURL').classList.toggle( + 'collapsed', + vAPI.localStorage.getItem('document-blocked-collapse-url') === 'true' + ); +})(); + +/******************************************************************************/ + +if ( window.history.length > 1 ) { + uDom('#back').on('click', function() { window.history.back(); }); + uDom('#bye').css('display', 'none'); +} else { + uDom('#bye').on('click', function() { window.close(); }); + uDom('#back').css('display', 'none'); +} + +/******************************************************************************/ + +// See if the target hostname is still blacklisted, and if not, navigate to it. + +vAPI.messaging.send('main-blocked.js', { + what: 'mustBlock', + scope: details.hn, + hostname: details.hn, + type: 'doc' +}, function(response) { + if ( response === false ) { + window.location.replace(details.url); + } +}); + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/js/matrix.js b/js/matrix.js new file mode 100644 index 0000000..59eb84a --- /dev/null +++ b/js/matrix.js @@ -0,0 +1,873 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global punycode */ +/* jshint bitwise: false */ + +'use strict'; + +/******************************************************************************/ + +µMatrix.Matrix = (function() { + +/******************************************************************************/ + +var µm = µMatrix; +var magicId = 'axyorpwxtmnf'; +var uniqueIdGenerator = 1; + +/******************************************************************************/ + +var Matrix = function() { + this.id = uniqueIdGenerator++; + this.reset(); + this.sourceRegister = ''; + this.decomposedSourceRegister = ['']; + this.specificityRegister = 0; +}; + +/******************************************************************************/ + +Matrix.Transparent = 0; +Matrix.Red = 1; +Matrix.Green = 2; +Matrix.Gray = 3; + +Matrix.Indirect = 0x00; +Matrix.Direct = 0x80; + +Matrix.RedDirect = Matrix.Red | Matrix.Direct; +Matrix.RedIndirect = Matrix.Red | Matrix.Indirect; +Matrix.GreenDirect = Matrix.Green | Matrix.Direct; +Matrix.GreenIndirect = Matrix.Green | Matrix.Indirect; +Matrix.GrayDirect = Matrix.Gray | Matrix.Direct; +Matrix.GrayIndirect = Matrix.Gray | Matrix.Indirect; + +/******************************************************************************/ + +var typeBitOffsets = new Map([ + [ '*', 0 ], + [ 'doc', 2 ], + [ 'cookie', 4 ], + [ 'css', 6 ], + [ 'image', 8 ], + [ 'media', 10 ], + [ 'script', 12 ], + [ 'xhr', 14 ], + [ 'frame', 16 ], + [ 'other', 18 ] +]); + +var stateToNameMap = new Map([ + [ 1, 'block' ], + [ 2, 'allow' ], + [ 3, 'inherit' ] +]); + +var nameToStateMap = { + 'block': 1, + 'allow': 2, + 'noop': 2, + 'inherit': 3 +}; + +var switchBitOffsets = new Map([ + [ 'matrix-off', 0 ], + [ 'https-strict', 2 ], + /* 4 is now unused, formerly assigned to UA spoofing */ + [ 'referrer-spoof', 6 ], + [ 'noscript-spoof', 8 ], + [ 'no-workers', 10 ] +]); + +var switchStateToNameMap = new Map([ + [ 1, 'true' ], + [ 2, 'false' ] +]); + +var nameToSwitchStateMap = { + 'true': 1, + 'false': 2 +}; + +/******************************************************************************/ + +Matrix.columnHeaderIndices = (function() { + var out = new Map(), + i = 0; + for ( var type of typeBitOffsets.keys() ) { + out.set(type, i++); + } + return out; +})(); + + +Matrix.switchNames = new Set(switchBitOffsets.keys()); + +/******************************************************************************/ + +// For performance purpose, as simple tests as possible +var reHostnameVeryCoarse = /[g-z_-]/; +var reIPv4VeryCoarse = /\.\d+$/; + +// http://tools.ietf.org/html/rfc5952 +// 4.3: "MUST be represented in lowercase" +// Also: http://en.wikipedia.org/wiki/IPv6_address#Literal_IPv6_addresses_in_network_resource_identifiers + +var isIPAddress = function(hostname) { + if ( reHostnameVeryCoarse.test(hostname) ) { + return false; + } + if ( reIPv4VeryCoarse.test(hostname) ) { + return true; + } + return hostname.charAt(0) === '['; +}; + +/******************************************************************************/ + +var toBroaderHostname = function(hostname) { + if ( hostname === '*' ) { return ''; } + if ( isIPAddress(hostname) ) { + return toBroaderIPAddress(hostname); + } + var pos = hostname.indexOf('.'); + if ( pos === -1 ) { + return '*'; + } + return hostname.slice(pos + 1); +}; + +var toBroaderIPAddress = function(ipaddress) { + // Can't broaden IPv6 (for now) + if ( ipaddress.charAt(0) === '[' ) { + return '*'; + } + var pos = ipaddress.lastIndexOf('.'); + return pos !== -1 ? ipaddress.slice(0, pos) : '*'; +}; + +Matrix.toBroaderHostname = toBroaderHostname; + +/******************************************************************************/ + +// Find out src-des relationship, using coarse-to-fine grained tests for +// speed. If desHostname is 1st-party to srcHostname, the domain is returned, +// otherwise the empty string. + +var extractFirstPartyDesDomain = function(srcHostname, desHostname) { + if ( srcHostname === '*' || desHostname === '*' || desHostname === '1st-party' ) { + return ''; + } + var µmuri = µm.URI; + var srcDomain = µmuri.domainFromHostname(srcHostname) || srcHostname; + var desDomain = µmuri.domainFromHostname(desHostname) || desHostname; + return desDomain === srcDomain ? desDomain : ''; +}; + +/******************************************************************************/ + +Matrix.prototype.reset = function() { + this.switches = new Map(); + this.rules = new Map(); + this.rootValue = Matrix.RedIndirect; + this.modifiedTime = 0; +}; + +/******************************************************************************/ + +Matrix.prototype.decomposeSource = function(srcHostname) { + if ( srcHostname === this.sourceRegister ) { return; } + var hn = srcHostname; + this.decomposedSourceRegister[0] = this.sourceRegister = hn; + var i = 1; + for (;;) { + hn = toBroaderHostname(hn); + this.decomposedSourceRegister[i++] = hn; + if ( hn === '' ) { break; } + } +}; + +/******************************************************************************/ + +// Copy another matrix to self. Do this incrementally to minimize impact on +// a live matrix. + +Matrix.prototype.assign = function(other) { + var k, entry; + // Remove rules not in other + for ( k of this.rules.keys() ) { + if ( other.rules.has(k) === false ) { + this.rules.delete(k); + } + } + // Remove switches not in other + for ( k of this.switches.keys() ) { + if ( other.switches.has(k) === false ) { + this.switches.delete(k); + } + } + // Add/change rules in other + for ( entry of other.rules ) { + this.rules.set(entry[0], entry[1]); + } + // Add/change switches in other + for ( entry of other.switches ) { + this.switches.set(entry[0], entry[1]); + } + this.modifiedTime = other.modifiedTime; + return this; +}; + +// https://www.youtube.com/watch?v=e9RS4biqyAc + +/******************************************************************************/ + +// If value is undefined, the switch is removed + +Matrix.prototype.setSwitch = function(switchName, srcHostname, newVal) { + var bitOffset = switchBitOffsets.get(switchName); + if ( bitOffset === undefined ) { + return false; + } + if ( newVal === this.evaluateSwitch(switchName, srcHostname) ) { + return false; + } + var bits = this.switches.get(srcHostname) || 0; + bits &= ~(3 << bitOffset); + bits |= newVal << bitOffset; + if ( bits === 0 ) { + this.switches.delete(srcHostname); + } else { + this.switches.set(srcHostname, bits); + } + this.modifiedTime = Date.now(); + return true; +}; + +/******************************************************************************/ + +Matrix.prototype.setCell = function(srcHostname, desHostname, type, state) { + var bitOffset = typeBitOffsets.get(type), + k = srcHostname + ' ' + desHostname, + oldBitmap = this.rules.get(k); + if ( oldBitmap === undefined ) { + oldBitmap = 0; + } + var newBitmap = oldBitmap & ~(3 << bitOffset) | (state << bitOffset); + if ( newBitmap === oldBitmap ) { + return false; + } + if ( newBitmap === 0 ) { + this.rules.delete(k); + } else { + this.rules.set(k, newBitmap); + } + this.modifiedTime = Date.now(); + return true; +}; + +/******************************************************************************/ + +Matrix.prototype.blacklistCell = function(srcHostname, desHostname, type) { + var r = this.evaluateCellZ(srcHostname, desHostname, type); + if ( r === 1 ) { + return false; + } + this.setCell(srcHostname, desHostname, type, 0); + r = this.evaluateCellZ(srcHostname, desHostname, type); + if ( r === 1 ) { + return true; + } + this.setCell(srcHostname, desHostname, type, 1); + return true; +}; + +/******************************************************************************/ + +Matrix.prototype.whitelistCell = function(srcHostname, desHostname, type) { + var r = this.evaluateCellZ(srcHostname, desHostname, type); + if ( r === 2 ) { + return false; + } + this.setCell(srcHostname, desHostname, type, 0); + r = this.evaluateCellZ(srcHostname, desHostname, type); + if ( r === 2 ) { + return true; + } + this.setCell(srcHostname, desHostname, type, 2); + return true; +}; + +/******************************************************************************/ + +Matrix.prototype.graylistCell = function(srcHostname, desHostname, type) { + var r = this.evaluateCellZ(srcHostname, desHostname, type); + if ( r === 0 || r === 3 ) { + return false; + } + this.setCell(srcHostname, desHostname, type, 0); + r = this.evaluateCellZ(srcHostname, desHostname, type); + if ( r === 0 || r === 3 ) { + return true; + } + this.setCell(srcHostname, desHostname, type, 3); + return true; +}; + +/******************************************************************************/ + +Matrix.prototype.evaluateCell = function(srcHostname, desHostname, type) { + var key = srcHostname + ' ' + desHostname; + var bitmap = this.rules.get(key); + if ( bitmap === undefined ) { + return 0; + } + return bitmap >> typeBitOffsets.get(type) & 3; +}; + +/******************************************************************************/ + +Matrix.prototype.evaluateCellZ = function(srcHostname, desHostname, type) { + this.decomposeSource(srcHostname); + + var bitOffset = typeBitOffsets.get(type), + s, v, i = 0; + for (;;) { + s = this.decomposedSourceRegister[i++]; + if ( s === '' ) { break; } + v = this.rules.get(s + ' ' + desHostname); + if ( v !== undefined ) { + v = v >> bitOffset & 3; + if ( v !== 0 ) { + return v; + } + } + } + // srcHostname is '*' at this point + + // Preset blacklisted hostnames are blacklisted in global scope + if ( type === '*' && µm.ubiquitousBlacklist.test(desHostname) ) { + return 1; + } + + // https://github.com/gorhill/uMatrix/issues/65 + // Hardcoded global `doc` rule + if ( type === 'doc' && desHostname === '*' ) { + return 2; + } + + return 0; +}; + +/******************************************************************************/ + +Matrix.prototype.evaluateCellZXY = function(srcHostname, desHostname, type) { + // Matrix filtering switch + this.specificityRegister = 0; + if ( this.evaluateSwitchZ('matrix-off', srcHostname) ) { + return Matrix.GreenIndirect; + } + + // TODO: There are cells evaluated twice when the type is '*'. Unsure + // whether it's worth trying to avoid that, as this could introduce + // overhead which may not be gained back by skipping the redundant tests. + // And this happens *only* when building the matrix UI, not when + // evaluating net requests. + + // Specific-hostname specific-type cell + this.specificityRegister = 1; + var r = this.evaluateCellZ(srcHostname, desHostname, type); + if ( r === 1 ) { return Matrix.RedDirect; } + if ( r === 2 ) { return Matrix.GreenDirect; } + + // Specific-hostname any-type cell + this.specificityRegister = 2; + var rl = this.evaluateCellZ(srcHostname, desHostname, '*'); + if ( rl === 1 ) { return Matrix.RedIndirect; } + + var d = desHostname; + var firstPartyDesDomain = extractFirstPartyDesDomain(srcHostname, desHostname); + + // Ancestor cells, up to 1st-party destination domain + if ( firstPartyDesDomain !== '' ) { + this.specificityRegister = 3; + for (;;) { + if ( d === firstPartyDesDomain ) { break; } + d = d.slice(d.indexOf('.') + 1); + + // specific-hostname specific-type cell + r = this.evaluateCellZ(srcHostname, d, type); + if ( r === 1 ) { return Matrix.RedIndirect; } + if ( r === 2 ) { return Matrix.GreenIndirect; } + // Do not override a narrower rule + if ( rl !== 2 ) { + rl = this.evaluateCellZ(srcHostname, d, '*'); + if ( rl === 1 ) { return Matrix.RedIndirect; } + } + } + + // 1st-party specific-type cell: it's a special row, looked up only + // when destination is 1st-party to source. + r = this.evaluateCellZ(srcHostname, '1st-party', type); + if ( r === 1 ) { return Matrix.RedIndirect; } + if ( r === 2 ) { return Matrix.GreenIndirect; } + // Do not override narrower rule + if ( rl !== 2 ) { + rl = this.evaluateCellZ(srcHostname, '1st-party', '*'); + if ( rl === 1 ) { return Matrix.RedIndirect; } + } + } + + // Keep going, up to root + this.specificityRegister = 4; + for (;;) { + d = toBroaderHostname(d); + if ( d === '*' ) { break; } + + // specific-hostname specific-type cell + r = this.evaluateCellZ(srcHostname, d, type); + if ( r === 1 ) { return Matrix.RedIndirect; } + if ( r === 2 ) { return Matrix.GreenIndirect; } + // Do not override narrower rule + if ( rl !== 2 ) { + rl = this.evaluateCellZ(srcHostname, d, '*'); + if ( rl === 1 ) { return Matrix.RedIndirect; } + } + } + + // Any-hostname specific-type cells + this.specificityRegister = 5; + r = this.evaluateCellZ(srcHostname, '*', type); + // Line below is strict-blocking + if ( r === 1 ) { return Matrix.RedIndirect; } + // Narrower rule wins + if ( rl === 2 ) { return Matrix.GreenIndirect; } + if ( r === 2 ) { return Matrix.GreenIndirect; } + + // Any-hostname any-type cell + this.specificityRegister = 6; + r = this.evaluateCellZ(srcHostname, '*', '*'); + if ( r === 1 ) { return Matrix.RedIndirect; } + if ( r === 2 ) { return Matrix.GreenIndirect; } + return this.rootValue; +}; + +// https://www.youtube.com/watch?v=4C5ZkwrnVfM + +/******************************************************************************/ + +Matrix.prototype.evaluateRowZXY = function(srcHostname, desHostname) { + var out = []; + for ( var type of typeBitOffsets.keys() ) { + out.push(this.evaluateCellZXY(srcHostname, desHostname, type)); + } + return out; +}; + +/******************************************************************************/ + +Matrix.prototype.mustBlock = function(srcHostname, desHostname, type) { + return (this.evaluateCellZXY(srcHostname, desHostname, type) & 3) === Matrix.Red; +}; + +/******************************************************************************/ + +Matrix.prototype.srcHostnameFromRule = function(rule) { + return rule.slice(0, rule.indexOf(' ')); +}; + +/******************************************************************************/ + +Matrix.prototype.desHostnameFromRule = function(rule) { + return rule.slice(rule.indexOf(' ') + 1); +}; + +/******************************************************************************/ + +Matrix.prototype.setSwitchZ = function(switchName, srcHostname, newState) { + var bitOffset = switchBitOffsets.get(switchName); + if ( bitOffset === undefined ) { + return false; + } + var state = this.evaluateSwitchZ(switchName, srcHostname); + if ( newState === state ) { + return false; + } + if ( newState === undefined ) { + newState = !state; + } + var bits = this.switches.get(srcHostname) || 0; + bits &= ~(3 << bitOffset); + if ( bits === 0 ) { + this.switches.delete(srcHostname); + } else { + this.switches.set(srcHostname, bits); + } + this.modifiedTime = Date.now(); + state = this.evaluateSwitchZ(switchName, srcHostname); + if ( state === newState ) { + return true; + } + this.switches.set(srcHostname, bits | ((newState ? 1 : 2) << bitOffset)); + return true; +}; + +/******************************************************************************/ + +// 0 = inherit from broader scope, up to default state +// 1 = non-default state +// 2 = forced default state (to override a broader non-default state) + +Matrix.prototype.evaluateSwitch = function(switchName, srcHostname) { + var bits = this.switches.get(srcHostname) || 0; + if ( bits === 0 ) { + return 0; + } + var bitOffset = switchBitOffsets.get(switchName); + if ( bitOffset === undefined ) { + return 0; + } + return (bits >> bitOffset) & 3; +}; + +/******************************************************************************/ + +Matrix.prototype.evaluateSwitchZ = function(switchName, srcHostname) { + var bitOffset = switchBitOffsets.get(switchName); + if ( bitOffset === undefined ) { return false; } + + this.decomposeSource(srcHostname); + + var s, bits, i = 0; + for (;;) { + s = this.decomposedSourceRegister[i++]; + if ( s === '' ) { break; } + bits = this.switches.get(s) || 0; + if ( bits !== 0 ) { + bits = bits >> bitOffset & 3; + if ( bits !== 0 ) { + return bits === 1; + } + } + } + return false; +}; + +/******************************************************************************/ + +Matrix.prototype.extractAllSourceHostnames = (function() { + var cachedResult = new Set(); + var matrixId = 0; + var readTime = 0; + + return function() { + if ( matrixId !== this.id || readTime !== this.modifiedTime ) { + cachedResult.clear(); + for ( var rule of this.rules.keys() ) { + cachedResult.add(rule.slice(0, rule.indexOf(' '))); + } + matrixId = this.id; + readTime = this.modifiedTime; + } + return cachedResult; + }; +})(); + +/******************************************************************************/ + +Matrix.prototype.toString = function() { + var out = []; + var rule, type, switchName, val; + var srcHostname, desHostname; + for ( rule of this.rules.keys() ) { + srcHostname = this.srcHostnameFromRule(rule); + desHostname = this.desHostnameFromRule(rule); + for ( type of typeBitOffsets.keys() ) { + val = this.evaluateCell(srcHostname, desHostname, type); + if ( val === 0 ) { continue; } + out.push( + punycode.toUnicode(srcHostname) + ' ' + + punycode.toUnicode(desHostname) + ' ' + + type + ' ' + + stateToNameMap.get(val) + ); + } + } + for ( srcHostname of this.switches.keys() ) { + for ( switchName of switchBitOffsets.keys() ) { + val = this.evaluateSwitch(switchName, srcHostname); + if ( val === 0 ) { continue; } + out.push(switchName + ': ' + srcHostname + ' ' + switchStateToNameMap.get(val)); + } + } + return out.sort().join('\n'); +}; + +/******************************************************************************/ + +Matrix.prototype.fromString = function(text, append) { + var matrix = append ? this : new Matrix(); + var textEnd = text.length; + var lineBeg = 0, lineEnd; + var line, pos; + var fields, fieldVal; + var switchName; + var srcHostname = ''; + var desHostname = ''; + var type, state; + + while ( lineBeg < textEnd ) { + lineEnd = text.indexOf('\n', lineBeg); + if ( lineEnd < 0 ) { + lineEnd = text.indexOf('\r', lineBeg); + if ( lineEnd < 0 ) { + lineEnd = textEnd; + } + } + line = text.slice(lineBeg, lineEnd).trim(); + lineBeg = lineEnd + 1; + + pos = line.indexOf('# '); + if ( pos !== -1 ) { + line = line.slice(0, pos).trim(); + } + if ( line === '' ) { + continue; + } + + fields = line.split(/\s+/); + + // Less than 2 fields makes no sense + if ( fields.length < 2 ) { + continue; + } + + fieldVal = fields[0]; + + // Special directives: + + // title + pos = fieldVal.indexOf('title:'); + if ( pos !== -1 ) { + // TODO + continue; + } + + // Name + pos = fieldVal.indexOf('name:'); + if ( pos !== -1 ) { + // TODO + continue; + } + + // Switch on/off + + // `switch:` srcHostname state + // state = [`true`, `false`] + switchName = ''; + if ( fieldVal === 'switch:' || fieldVal === 'matrix:' ) { + fieldVal = 'matrix-off:'; + } + pos = fieldVal.indexOf(':'); + if ( pos !== -1 ) { + switchName = fieldVal.slice(0, pos); + } + if ( switchBitOffsets.has(switchName) ) { + srcHostname = punycode.toASCII(fields[1]); + + // No state field: reject + fieldVal = fields[2]; + if ( fieldVal === null ) { + continue; + } + // Unknown state: reject + if ( nameToSwitchStateMap.hasOwnProperty(fieldVal) === false ) { + continue; + } + + // Backward compatibility: + // `chromium-behind-the-scene` is now `behind-the-scene` + if ( srcHostname === 'chromium-behind-the-scene' ) { + srcHostname = 'behind-the-scene'; + } + + matrix.setSwitch(switchName, srcHostname, nameToSwitchStateMap[fieldVal]); + continue; + } + + // Unknown directive + if ( fieldVal.endsWith(':') ) { + continue; + } + + // Valid rule syntax: + + // srcHostname desHostname [type [state]] + // type = a valid request type + // state = [`block`, `allow`, `inherit`] + + // srcHostname desHostname type + // type = a valid request type + // state = `allow` + + // srcHostname desHostname + // type = `*` + // state = `allow` + + // Lines with invalid syntax silently ignored + + srcHostname = punycode.toASCII(fields[0]); + desHostname = punycode.toASCII(fields[1]); + + fieldVal = fields[2]; + + if ( fieldVal !== undefined ) { + type = fieldVal; + // https://github.com/gorhill/uMatrix/issues/759 + // Backward compatibility. + if ( type === 'plugin' ) { + type = 'media'; + } + // Unknown type: reject + if ( typeBitOffsets.has(type) === false ) { + continue; + } + } else { + type = '*'; + } + + fieldVal = fields[3]; + + if ( fieldVal !== undefined ) { + // Unknown state: reject + if ( nameToStateMap.hasOwnProperty(fieldVal) === false ) { + continue; + } + state = nameToStateMap[fieldVal]; + } else { + state = 2; + } + + matrix.setCell(srcHostname, desHostname, type, state); + } + + if ( !append ) { + this.assign(matrix); + } + + this.modifiedTime = Date.now(); +}; + +/******************************************************************************/ + +Matrix.prototype.toSelfie = function() { + return { + magicId: magicId, + switches: Array.from(this.switches), + rules: Array.from(this.rules) + }; +}; + +/******************************************************************************/ + +Matrix.prototype.fromSelfie = function(selfie) { + if ( selfie.magicId !== magicId ) { return false; } + this.switches = new Map(selfie.switches); + this.rules = new Map(selfie.rules); + this.modifiedTime = Date.now(); + return true; +}; + +/******************************************************************************/ + +Matrix.prototype.diff = function(other, srcHostname, desHostnames) { + var out = []; + var desHostname, type; + var switchName, i, thisVal, otherVal; + for (;;) { + for ( switchName of switchBitOffsets.keys() ) { + thisVal = this.evaluateSwitch(switchName, srcHostname); + otherVal = other.evaluateSwitch(switchName, srcHostname); + if ( thisVal !== otherVal ) { + out.push({ + 'what': switchName, + 'src': srcHostname + }); + } + } + i = desHostnames.length; + while ( i-- ) { + desHostname = desHostnames[i]; + for ( type of typeBitOffsets.keys() ) { + thisVal = this.evaluateCell(srcHostname, desHostname, type); + otherVal = other.evaluateCell(srcHostname, desHostname, type); + if ( thisVal === otherVal ) { continue; } + out.push({ + 'what': 'rule', + 'src': srcHostname, + 'des': desHostname, + 'type': type + }); + } + } + srcHostname = toBroaderHostname(srcHostname); + if ( srcHostname === '' ) { + break; + } + } + return out; +}; + +/******************************************************************************/ + +Matrix.prototype.applyDiff = function(diff, from) { + var changed = false; + var i = diff.length; + var action, val; + while ( i-- ) { + action = diff[i]; + if ( action.what === 'rule' ) { + val = from.evaluateCell(action.src, action.des, action.type); + changed = this.setCell(action.src, action.des, action.type, val) || changed; + continue; + } + if ( switchBitOffsets.has(action.what) ) { + val = from.evaluateSwitch(action.what, action.src); + changed = this.setSwitch(action.what, action.src, val) || changed; + continue; + } + } + return changed; +}; + +/******************************************************************************/ + +return Matrix; + +/******************************************************************************/ + +// https://www.youtube.com/watch?v=wlNrQGmj6oQ + +})(); + +/******************************************************************************/ diff --git a/js/messaging.js b/js/messaging.js new file mode 100644 index 0000000..d5c472f --- /dev/null +++ b/js/messaging.js @@ -0,0 +1,963 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +'use strict'; + +/******************************************************************************/ +/******************************************************************************/ + +// Default handler + +(function() { + +var µm = µMatrix; + +/******************************************************************************/ + +// Default is for commonly used message. + +function onMessage(request, sender, callback) { + // Async + switch ( request.what ) { + case 'getAssetContent': + µm.assets.get(request.url, { dontCache: true }, callback); + return; + + case 'selectHostsFiles': + µm.selectHostsFiles(request, callback); + return; + + default: + break; + } + + // Sync + var response; + + switch ( request.what ) { + case 'forceReloadTab': + µm.forceReload(request.tabId, request.bypassCache); + break; + + case 'forceUpdateAssets': + µm.scheduleAssetUpdater(0); + µm.assets.updateStart({ delay: 2000 }); + break; + + case 'getUserSettings': + response = { + userSettings: µm.userSettings, + matrixSwitches: { + 'https-strict': µm.pMatrix.evaluateSwitch('https-strict', '*') === 1, + 'referrer-spoof': µm.pMatrix.evaluateSwitch('referrer-spoof', '*') === 1, + 'noscript-spoof': µm.pMatrix.evaluateSwitch('noscript-spoof', '*') === 1 + } + }; + break; + + case 'gotoExtensionURL': + µm.gotoExtensionURL(request); + break; + + case 'gotoURL': + µm.gotoURL(request); + break; + + case 'mustBlock': + response = µm.mustBlock( + request.scope, + request.hostname, + request.type + ); + break; + + case 'readRawSettings': + response = µm.stringFromRawSettings(); + break; + + case 'reloadHostsFiles': + µm.reloadHostsFiles(); + break; + + case 'setMatrixSwitch': + µm.tMatrix.setSwitch(request.switchName, '*', request.state); + if ( µm.pMatrix.setSwitch(request.switchName, '*', request.state) ) { + µm.saveMatrix(); + } + break; + + case 'userSettings': + if ( request.hasOwnProperty('value') === false ) { + request.value = undefined; + } + response = µm.changeUserSettings(request.name, request.value); + break; + + case 'writeRawSettings': + µm.rawSettingsFromString(request.content); + break; + + default: + return vAPI.messaging.UNHANDLED; + } + + callback(response); +} + +/******************************************************************************/ + +vAPI.messaging.setup(onMessage); + +/******************************************************************************/ + +})(); + +/******************************************************************************/ +/******************************************************************************/ + +(function() { + +// popup.js + +var µm = µMatrix; + +/******************************************************************************/ + +// Constructor is faster than object literal + +var RowSnapshot = function(srcHostname, desHostname, desDomain) { + this.domain = desDomain; + this.temporary = µm.tMatrix.evaluateRowZXY(srcHostname, desHostname); + this.permanent = µm.pMatrix.evaluateRowZXY(srcHostname, desHostname); + this.counts = RowSnapshot.counts.slice(); + this.totals = RowSnapshot.counts.slice(); +}; + +RowSnapshot.counts = (function() { + var aa = []; + for ( var i = 0, n = µm.Matrix.columnHeaderIndices.size; i < n; i++ ) { + aa[i] = 0; + } + return aa; +})(); + +/******************************************************************************/ + +var matrixSnapshot = function(pageStore, details) { + var µmuser = µm.userSettings; + var headerIndices = µm.Matrix.columnHeaderIndices; + + var r = { + appVersion: vAPI.app.version, + blockedCount: pageStore.requestStats.blocked.all, + collapseAllDomains: µmuser.popupCollapseAllDomains, + collapseBlacklistedDomains: µmuser.popupCollapseBlacklistedDomains, + diff: [], + domain: pageStore.pageDomain, + has3pReferrer: pageStore.has3pReferrer, + hasMixedContent: pageStore.hasMixedContent, + hasNoscriptTags: pageStore.hasNoscriptTags, + hasWebWorkers: pageStore.hasWebWorkers, + headerIndices: Array.from(headerIndices), + hostname: pageStore.pageHostname, + mtxContentModified: pageStore.mtxContentModifiedTime !== details.mtxContentModifiedTime, + mtxCountModified: pageStore.mtxCountModifiedTime !== details.mtxCountModifiedTime, + mtxContentModifiedTime: pageStore.mtxContentModifiedTime, + mtxCountModifiedTime: pageStore.mtxCountModifiedTime, + pMatrixModified: µm.pMatrix.modifiedTime !== details.pMatrixModifiedTime, + pMatrixModifiedTime: µm.pMatrix.modifiedTime, + pSwitches: {}, + rows: {}, + rowCount: 0, + scope: '*', + tabId: pageStore.tabId, + tMatrixModified: µm.tMatrix.modifiedTime !== details.tMatrixModifiedTime, + tMatrixModifiedTime: µm.tMatrix.modifiedTime, + tSwitches: {}, + url: pageStore.pageUrl, + userSettings: { + colorBlindFriendly: µmuser.colorBlindFriendly, + displayTextSize: µmuser.displayTextSize, + popupScopeLevel: µmuser.popupScopeLevel + } + }; + + if ( typeof details.scope === 'string' ) { + r.scope = details.scope; + } else if ( µmuser.popupScopeLevel === 'site' ) { + r.scope = r.hostname; + } else if ( µmuser.popupScopeLevel === 'domain' ) { + r.scope = r.domain; + } + + for ( var switchName of µm.Matrix.switchNames ) { + r.tSwitches[switchName] = µm.tMatrix.evaluateSwitchZ(switchName, r.scope); + r.pSwitches[switchName] = µm.pMatrix.evaluateSwitchZ(switchName, r.scope); + } + + // These rows always exist + r.rows['*'] = new RowSnapshot(r.scope, '*', '*'); + r.rows['1st-party'] = new RowSnapshot(r.scope, '1st-party', '1st-party'); + r.rowCount += 1; + + var µmuri = µm.URI; + var reqType, reqHostname, reqDomain; + var desHostname; + var row, typeIndex; + var anyIndex = headerIndices.get('*'); + var pos, count; + + for ( var entry of pageStore.hostnameTypeCells ) { + pos = entry[0].indexOf(' '); + reqHostname = entry[0].slice(0, pos); + reqType = entry[0].slice(pos + 1); + // rhill 2013-10-23: hostname can be empty if the request is a data url + // https://github.com/gorhill/httpswitchboard/issues/26 + if ( reqHostname === '' ) { + reqHostname = pageStore.pageHostname; + } + reqDomain = µmuri.domainFromHostname(reqHostname) || reqHostname; + + // We want rows of self and ancestors + desHostname = reqHostname; + for (;;) { + // If row exists, ancestors exist + if ( r.rows.hasOwnProperty(desHostname) !== false ) { break; } + r.rows[desHostname] = new RowSnapshot(r.scope, desHostname, reqDomain); + r.rowCount += 1; + if ( desHostname === reqDomain ) { break; } + pos = desHostname.indexOf('.'); + if ( pos === -1 ) { break; } + desHostname = desHostname.slice(pos + 1); + } + + count = entry[1].size; + typeIndex = headerIndices.get(reqType); + row = r.rows[reqHostname]; + row.counts[typeIndex] += count; + row.counts[anyIndex] += count; + row = r.rows[reqDomain]; + row.totals[typeIndex] += count; + row.totals[anyIndex] += count; + row = r.rows['*']; + row.totals[typeIndex] += count; + row.totals[anyIndex] += count; + } + + r.diff = µm.tMatrix.diff(µm.pMatrix, r.hostname, Object.keys(r.rows)); + + return r; +}; + +/******************************************************************************/ + +var matrixSnapshotFromTabId = function(details, callback) { + var matrixSnapshotIf = function(tabId, details) { + var pageStore = µm.pageStoreFromTabId(tabId); + if ( pageStore === null ) { + callback('ENOTFOUND'); + return; + } + + // First verify whether we must return data or not. + if ( + µm.tMatrix.modifiedTime === details.tMatrixModifiedTime && + µm.pMatrix.modifiedTime === details.pMatrixModifiedTime && + pageStore.mtxContentModifiedTime === details.mtxContentModifiedTime && + pageStore.mtxCountModifiedTime === details.mtxCountModifiedTime + ) { + callback('ENOCHANGE'); + return ; + } + + callback(matrixSnapshot(pageStore, details)); + }; + + // Specific tab id requested? + if ( details.tabId ) { + matrixSnapshotIf(details.tabId, details); + return; + } + + // Fall back to currently active tab + var onTabReady = function(tab) { + if ( tab instanceof Object === false ) { + callback('ENOTFOUND'); + return; + } + + // Allow examination of behind-the-scene requests + var tabId = tab.url.lastIndexOf(vAPI.getURL('dashboard.html'), 0) !== 0 ? + tab.id : + vAPI.noTabId; + matrixSnapshotIf(tabId, details); + }; + + vAPI.tabs.get(null, onTabReady); +}; + +/******************************************************************************/ + +var onMessage = function(request, sender, callback) { + // Async + switch ( request.what ) { + case 'matrixSnapshot': + matrixSnapshotFromTabId(request, callback); + return; + + default: + break; + } + + // Sync + var response; + + switch ( request.what ) { + case 'toggleMatrixSwitch': + µm.tMatrix.setSwitchZ( + request.switchName, + request.srcHostname, + µm.tMatrix.evaluateSwitchZ(request.switchName, request.srcHostname) === false + ); + break; + + case 'blacklistMatrixCell': + µm.tMatrix.blacklistCell( + request.srcHostname, + request.desHostname, + request.type + ); + break; + + case 'whitelistMatrixCell': + µm.tMatrix.whitelistCell( + request.srcHostname, + request.desHostname, + request.type + ); + break; + + case 'graylistMatrixCell': + µm.tMatrix.graylistCell( + request.srcHostname, + request.desHostname, + request.type + ); + break; + + case 'applyDiffToPermanentMatrix': // aka "persist" + if ( µm.pMatrix.applyDiff(request.diff, µm.tMatrix) ) { + µm.saveMatrix(); + } + break; + + case 'applyDiffToTemporaryMatrix': // aka "revert" + µm.tMatrix.applyDiff(request.diff, µm.pMatrix); + break; + + case 'revertTemporaryMatrix': + µm.tMatrix.assign(µm.pMatrix); + break; + + default: + return vAPI.messaging.UNHANDLED; + } + + callback(response); +}; + +vAPI.messaging.listen('popup.js', onMessage); + +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// content scripts + +(function() { + +var µm = µMatrix; + +/******************************************************************************/ + +var foundInlineCode = function(tabId, pageStore, details, type) { + if ( pageStore === null ) { return; } + + var pageHostname = pageStore.pageHostname, + µmuri = µm.URI.set(details.documentURI), + frameURL = µmuri.normalizedURI(); + + var blocked = details.blocked; + if ( blocked === undefined ) { + blocked = µm.mustBlock(pageHostname, µmuri.hostname, type); + } + + var mapTo = { + css: 'style', + script: 'script' + }; + + // https://github.com/gorhill/httpswitchboard/issues/333 + // Look-up here whether inline scripting is blocked for the frame. + var url = frameURL + '{inline_' + mapTo[type] + '}'; + pageStore.recordRequest(type, url, blocked); + µm.logger.writeOne(tabId, 'net', pageHostname, url, type, blocked); +}; + +/******************************************************************************/ + +var contentScriptLocalStorageHandler = function(tabId, originURL) { + var tabContext = µm.tabContextManager.lookup(tabId); + if ( tabContext === null ) { return; } + + var blocked = µm.mustBlock( + tabContext.rootHostname, + µm.URI.hostnameFromURI(originURL), + 'cookie' + ); + + var pageStore = µm.pageStoreFromTabId(tabId); + if ( pageStore !== null ) { + var requestURL = originURL + '/{localStorage}'; + pageStore.recordRequest('cookie', requestURL, blocked); + µm.logger.writeOne(tabId, 'net', tabContext.rootHostname, requestURL, 'cookie', blocked); + } + + var removeStorage = blocked && µm.userSettings.deleteLocalStorage; + if ( removeStorage ) { + µm.localStorageRemovedCounter++; + } + + return removeStorage; +}; + +/******************************************************************************/ + +// Evaluate many URLs against the matrix. + +var lookupBlockedCollapsibles = function(tabId, requests) { + if ( placeholdersReadTime < µm.rawSettingsWriteTime ) { + placeholders = undefined; + } + + if ( placeholders === undefined ) { + placeholders = { + frame: µm.rawSettings.framePlaceholder, + image: µm.rawSettings.imagePlaceholder + }; + if ( placeholders.frame ) { + placeholders.frameDocument = + µm.rawSettings.framePlaceholderDocument.replace( + '{{bg}}', + µm.rawSettings.framePlaceholderBackground !== 'default' ? + µm.rawSettings.framePlaceholderBackground : + µm.rawSettings.placeholderBackground + ); + } + if ( placeholders.image ) { + placeholders.imageBorder = + µm.rawSettings.imagePlaceholderBorder !== 'default' ? + µm.rawSettings.imagePlaceholderBorder : + µm.rawSettings.placeholderBorder; + placeholders.imageBackground = + µm.rawSettings.imagePlaceholderBackground !== 'default' ? + µm.rawSettings.imagePlaceholderBackground : + µm.rawSettings.placeholderBackground; + } + placeholdersReadTime = Date.now(); + } + + var response = { + blockedResources: [], + hash: requests.hash, + id: requests.id, + placeholders: placeholders + }; + + var tabContext = µm.tabContextManager.lookup(tabId); + if ( tabContext === null ) { + return response; + } + + var pageStore = µm.pageStoreFromTabId(tabId); + if ( pageStore !== null ) { + pageStore.lookupBlockedCollapsibles(requests, response); + } + + return response; +}; + +var placeholders, + placeholdersReadTime = 0; + +/******************************************************************************/ + +var onMessage = function(request, sender, callback) { + // Async + switch ( request.what ) { + default: + break; + } + + var tabId = sender && sender.tab ? sender.tab.id || 0 : 0, + tabContext = µm.tabContextManager.lookup(tabId), + rootHostname = tabContext && tabContext.rootHostname, + pageStore = µm.pageStoreFromTabId(tabId); + + // Sync + var response; + + switch ( request.what ) { + case 'contentScriptHasLocalStorage': + response = contentScriptLocalStorageHandler(tabId, request.originURL); + break; + + case 'lookupBlockedCollapsibles': + response = lookupBlockedCollapsibles(tabId, request); + break; + + case 'mustRenderNoscriptTags?': + if ( tabContext === null ) { break; } + response = + µm.tMatrix.mustBlock(rootHostname, rootHostname, 'script') && + µm.tMatrix.evaluateSwitchZ('noscript-spoof', rootHostname); + if ( pageStore !== null ) { + pageStore.hasNoscriptTags = true; + } + // https://github.com/gorhill/uMatrix/issues/225 + // A good place to force an update of the page title, as at + // this point the DOM has been loaded. + µm.updateTitle(tabId); + break; + + case 'securityPolicyViolation': + if ( request.directive === 'worker-src' ) { + var url = µm.URI.hostnameFromURI(request.blockedURI) !== '' ? + request.blockedURI : + request.documentURI; + if ( pageStore !== null ) { + pageStore.hasWebWorkers = true; + pageStore.recordRequest('script', url, true); + } + if ( tabContext !== null ) { + µm.logger.writeOne(tabId, 'net', rootHostname, url, 'worker', request.blocked); + } + } else if ( request.directive === 'script-src' ) { + foundInlineCode(tabId, pageStore, request, 'script'); + } else if ( request.directive === 'style-src' ) { + foundInlineCode(tabId, pageStore, request, 'css'); + } + break; + + case 'shutdown?': + if ( tabContext !== null ) { + response = µm.tMatrix.evaluateSwitchZ('matrix-off', rootHostname); + } + break; + + default: + return vAPI.messaging.UNHANDLED; + } + + callback(response); +}; + +vAPI.messaging.listen('contentscript.js', onMessage); + +/******************************************************************************/ + +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// cloud-ui.js + +(function() { + +/******************************************************************************/ + +var µm = µMatrix; + +/******************************************************************************/ + +var onMessage = function(request, sender, callback) { + // Async + switch ( request.what ) { + case 'cloudGetOptions': + vAPI.cloud.getOptions(function(options) { + options.enabled = µm.userSettings.cloudStorageEnabled === true; + callback(options); + }); + return; + + case 'cloudSetOptions': + vAPI.cloud.setOptions(request.options, callback); + return; + + case 'cloudPull': + return vAPI.cloud.pull(request.datakey, callback); + + case 'cloudPush': + return vAPI.cloud.push(request.datakey, request.data, callback); + + default: + break; + } + + // Sync + var response; + + switch ( request.what ) { + // For when cloud storage is disabled. + case 'cloudPull': + // fallthrough + case 'cloudPush': + break; + + default: + return vAPI.messaging.UNHANDLED; + } + + callback(response); +}; + +/******************************************************************************/ + +vAPI.messaging.listen('cloud-ui.js', onMessage); + +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// user-rules.js + +(function() { + +var µm = µMatrix; + +/******************************************************************************/ + +var onMessage = function(request, sender, callback) { + + // Async + switch ( request.what ) { + default: + break; + } + + // Sync + var response; + + switch ( request.what ) { + case 'getUserRules': + response = { + temporaryRules: µm.tMatrix.toString(), + permanentRules: µm.pMatrix.toString() + }; + break; + + case 'setUserRules': + if ( typeof request.temporaryRules === 'string' ) { + µm.tMatrix.fromString(request.temporaryRules); + } + if ( typeof request.permanentRules === 'string' ) { + µm.pMatrix.fromString(request.permanentRules); + µm.saveMatrix(); + } + response = { + temporaryRules: µm.tMatrix.toString(), + permanentRules: µm.pMatrix.toString() + }; + break; + + default: + return vAPI.messaging.UNHANDLED; + } + + callback(response); +}; + +vAPI.messaging.listen('user-rules.js', onMessage); + +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// hosts-files.js + +(function() { + +var µm = µMatrix; + +/******************************************************************************/ + +var prepEntries = function(entries) { + var µmuri = µm.URI; + var entry; + for ( var k in entries ) { + if ( entries.hasOwnProperty(k) === false ) { + continue; + } + entry = entries[k]; + if ( typeof entry.homeURL === 'string' ) { + entry.homeHostname = µmuri.hostnameFromURI(entry.homeURL); + entry.homeDomain = µmuri.domainFromHostname(entry.homeHostname); + } + } +}; + +/******************************************************************************/ + +var getLists = function(callback) { + var r = { + autoUpdate: µm.userSettings.autoUpdate, + available: null, + cache: null, + current: µm.liveHostsFiles, + blockedHostnameCount: µm.ubiquitousBlacklist.count + }; + var onMetadataReady = function(entries) { + r.cache = entries; + prepEntries(r.cache); + callback(r); + }; + var onAvailableHostsFilesReady = function(lists) { + r.available = lists; + prepEntries(r.available); + µm.assets.metadata(onMetadataReady); + }; + µm.getAvailableHostsFiles(onAvailableHostsFilesReady); +}; + +/******************************************************************************/ + +var onMessage = function(request, sender, callback) { + var µm = µMatrix; + + // Async + switch ( request.what ) { + case 'getLists': + return getLists(callback); + + default: + break; + } + + // Sync + var response; + + switch ( request.what ) { + case 'purgeCache': + µm.assets.purge(request.assetKey); + µm.assets.remove('compiled/' + request.assetKey); + break; + + case 'purgeAllCaches': + if ( request.hard ) { + µm.assets.remove(/./); + } else { + µm.assets.purge(/./, 'public_suffix_list.dat'); + } + break; + + default: + return vAPI.messaging.UNHANDLED; + } + + callback(response); +}; + +vAPI.messaging.listen('hosts-files.js', onMessage); + +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// about.js + +(function() { + +var µm = µMatrix; + +/******************************************************************************/ + +var restoreUserData = function(userData) { + var countdown = 4; + var onCountdown = function() { + countdown -= 1; + if ( countdown === 0 ) { + vAPI.app.restart(); + } + }; + + var onAllRemoved = function() { + vAPI.storage.set(userData.settings, onCountdown); + vAPI.storage.set({ userMatrix: userData.rules }, onCountdown); + vAPI.storage.set({ liveHostsFiles: userData.hostsFiles }, onCountdown); + if ( userData.rawSettings instanceof Object ) { + µMatrix.saveRawSettings(userData.rawSettings, onCountdown); + } + }; + + // If we are going to restore all, might as well wipe out clean local + // storage + µm.XAL.keyvalRemoveAll(onAllRemoved); +}; + +/******************************************************************************/ + +var resetUserData = function() { + var onAllRemoved = function() { + vAPI.app.restart(); + }; + µm.XAL.keyvalRemoveAll(onAllRemoved); +}; + +/******************************************************************************/ + +var onMessage = function(request, sender, callback) { + // Async + switch ( request.what ) { + default: + break; + } + + // Sync + var response; + + switch ( request.what ) { + case 'getAllUserData': + response = { + app: vAPI.app.name, + version: vAPI.app.version, + when: Date.now(), + settings: µm.userSettings, + rules: µm.pMatrix.toString(), + hostsFiles: µm.liveHostsFiles, + rawSettings: µm.rawSettings + }; + break; + + case 'getSomeStats': + response = { + version: vAPI.app.version, + storageUsed: µm.storageUsed + }; + break; + + case 'restoreAllUserData': + restoreUserData(request.userData); + break; + + case 'resetAllUserData': + resetUserData(); + break; + + default: + return vAPI.messaging.UNHANDLED; + } + + callback(response); +}; + +vAPI.messaging.listen('about.js', onMessage); + +/******************************************************************************/ +/******************************************************************************/ + +// logger-ui.js + +(function() { + +/******************************************************************************/ + +var µm = µMatrix, + loggerURL = vAPI.getURL('logger-ui.html'); + +/******************************************************************************/ + +var onMessage = function(request, sender, callback) { + // Async + switch ( request.what ) { + default: + break; + } + + // Sync + var response; + + switch ( request.what ) { + case 'readMany': + if ( + µm.logger.ownerId !== undefined && + request.ownerId !== µm.logger.ownerId + ) { + response = { unavailable: true }; + break; + } + var tabIds = {}; + for ( var tabId in µm.pageStores ) { + var pageStore = µm.pageStoreFromTabId(tabId); + if ( pageStore === null ) { continue; } + if ( pageStore.rawUrl.startsWith(loggerURL) ) { continue; } + tabIds[tabId] = pageStore.title || pageStore.rawUrl; + } + response = { + colorBlind: false, + entries: µm.logger.readAll(request.ownerId), + maxLoggedRequests: µm.userSettings.maxLoggedRequests, + noTabId: vAPI.noTabId, + tabIds: tabIds, + tabIdsToken: µm.pageStoresToken + }; + break; + + case 'releaseView': + if ( request.ownerId === µm.logger.ownerId ) { + µm.logger.ownerId = undefined; + } + break; + + default: + return vAPI.messaging.UNHANDLED; + } + + callback(response); +}; + +vAPI.messaging.listen('logger-ui.js', onMessage); + +/******************************************************************************/ + +})(); + +/******************************************************************************/ +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/js/pagestats.js b/js/pagestats.js new file mode 100644 index 0000000..37e94c7 --- /dev/null +++ b/js/pagestats.js @@ -0,0 +1,274 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2013-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +'use strict'; + +/******************************************************************************/ + +µMatrix.pageStoreFactory = (function() { + +/******************************************************************************/ + +var µm = µMatrix; + +/******************************************************************************/ + +var BlockedCollapsibles = function() { + this.boundPruneAsyncCallback = this.pruneAsyncCallback.bind(this); + this.blocked = new Map(); + this.hash = 0; + this.timer = null; +}; + +BlockedCollapsibles.prototype = { + + shelfLife: 10 * 1000, + + add: function(type, url, isSpecific) { + if ( this.blocked.size === 0 ) { this.pruneAsync(); } + var now = Date.now() / 1000 | 0; + // The following "trick" is to encode the specifity into the lsb of the + // time stamp so as to avoid to have to allocate a memory structure to + // store both time stamp and specificity. + if ( isSpecific ) { + now |= 0x00000001; + } else { + now &= 0xFFFFFFFE; + } + this.blocked.set(type + ' ' + url, now); + this.hash = now; + }, + + reset: function() { + this.blocked.clear(); + this.hash = 0; + if ( this.timer !== null ) { + clearTimeout(this.timer); + this.timer = null; + } + }, + + pruneAsync: function() { + if ( this.timer === null ) { + this.timer = vAPI.setTimeout( + this.boundPruneAsyncCallback, + this.shelfLife * 2 + ); + } + }, + + pruneAsyncCallback: function() { + this.timer = null; + var obsolete = Date.now() - this.shelfLife; + for ( var entry of this.blocked ) { + if ( entry[1] <= obsolete ) { + this.blocked.delete(entry[0]); + } + } + if ( this.blocked.size !== 0 ) { this.pruneAsync(); } + } +}; + +/******************************************************************************/ + +// Ref: Given a URL, returns a (somewhat) unique 32-bit value +// Based on: FNV32a +// http://www.isthe.com/chongo/tech/comp/fnv/index.html#FNV-reference-source +// The rest is custom, suited for uMatrix. + +var PageStore = function(tabContext) { + this.hostnameTypeCells = new Map(); + this.domains = new Set(); + this.blockedCollapsibles = new BlockedCollapsibles(); + this.requestStats = µm.requestStatsFactory(); + this.off = false; + this.init(tabContext); +}; + +PageStore.prototype = { + + collapsibleTypes: new Set([ 'image' ]), + pageStoreJunkyard: [], + + init: function(tabContext) { + this.tabId = tabContext.tabId; + this.rawUrl = tabContext.rawURL; + this.pageUrl = tabContext.normalURL; + this.pageHostname = tabContext.rootHostname; + this.pageDomain = tabContext.rootDomain; + this.title = ''; + this.hostnameTypeCells.clear(); + this.domains.clear(); + this.allHostnamesString = ' '; + this.blockedCollapsibles.reset(); + this.requestStats.reset(); + this.distinctRequestCount = 0; + this.perLoadAllowedRequestCount = 0; + this.perLoadBlockedRequestCount = 0; + this.has3pReferrer = false; + this.hasMixedContent = false; + this.hasNoscriptTags = false; + this.hasWebWorkers = false; + this.incinerationTimer = null; + this.mtxContentModifiedTime = 0; + this.mtxCountModifiedTime = 0; + return this; + }, + + dispose: function() { + this.rawUrl = ''; + this.pageUrl = ''; + this.pageHostname = ''; + this.pageDomain = ''; + this.title = ''; + this.hostnameTypeCells.clear(); + this.domains.clear(); + this.allHostnamesString = ' '; + this.blockedCollapsibles.reset(); + if ( this.incinerationTimer !== null ) { + clearTimeout(this.incinerationTimer); + this.incinerationTimer = null; + } + if ( this.pageStoreJunkyard.length < 8 ) { + this.pageStoreJunkyard.push(this); + } + }, + + cacheBlockedCollapsible: function(type, url, specificity) { + if ( this.collapsibleTypes.has(type) ) { + this.blockedCollapsibles.add( + type, + url, + specificity !== 0 && specificity < 5 + ); + } + }, + + lookupBlockedCollapsibles: function(request, response) { + var tabContext = µm.tabContextManager.lookup(this.tabId); + if ( tabContext === null ) { return; } + + var collapseBlacklisted = µm.userSettings.collapseBlacklisted, + collapseBlocked = µm.userSettings.collapseBlocked, + entry; + + var blockedResources = response.blockedResources; + + if ( + Array.isArray(request.toFilter) && + request.toFilter.length !== 0 + ) { + var roothn = tabContext.rootHostname, + hnFromURI = µm.URI.hostnameFromURI, + tMatrix = µm.tMatrix; + for ( entry of request.toFilter ) { + if ( tMatrix.mustBlock(roothn, hnFromURI(entry.url), entry.type) === false ) { + continue; + } + blockedResources.push([ + entry.type + ' ' + entry.url, + collapseBlocked || + collapseBlacklisted && tMatrix.specificityRegister !== 0 && + tMatrix.specificityRegister < 5 + ]); + } + } + + if ( this.blockedCollapsibles.hash === response.hash ) { return; } + response.hash = this.blockedCollapsibles.hash; + + for ( entry of this.blockedCollapsibles.blocked ) { + blockedResources.push([ + entry[0], + collapseBlocked || collapseBlacklisted && (entry[1] & 1) !== 0 + ]); + } + }, + + recordRequest: function(type, url, block) { + // Store distinct network requests. This is used to: + // - remember which hostname/type were seen + // - count the number of distinct URLs for any given + // hostname-type pair + var hostname = µm.URI.hostnameFromURI(url), + key = hostname + ' ' + type, + uids = this.hostnameTypeCells.get(key); + if ( uids === undefined ) { + this.hostnameTypeCells.set(key, (uids = new Set())); + } else if ( uids.size > 99 ) { + return; + } + var uid = this.uidFromURL(url); + if ( uids.has(uid) ) { return; } + uids.add(uid); + + // Count blocked/allowed requests + this.requestStats.record(type, block); + + // https://github.com/gorhill/httpswitchboard/issues/306 + // If it is recorded locally, record globally + µm.requestStats.record(type, block); + µm.updateBadgeAsync(this.tabId); + + if ( block !== false ) { + this.perLoadBlockedRequestCount++; + } else { + this.perLoadAllowedRequestCount++; + } + + this.distinctRequestCount++; + this.mtxCountModifiedTime = Date.now(); + + if ( this.domains.has(hostname) === false ) { + this.domains.add(hostname); + this.allHostnamesString += hostname + ' '; + this.mtxContentModifiedTime = Date.now(); + } + }, + + uidFromURL: function(uri) { + var hint = 0x811c9dc5, + i = uri.length; + while ( i-- ) { + hint ^= uri.charCodeAt(i) | 0; + hint += (hint<<1) + (hint<<4) + (hint<<7) + (hint<<8) + (hint<<24) | 0; + hint >>>= 0; + } + return hint; + } +}; + +/******************************************************************************/ + +return function pageStoreFactory(tabContext) { + var entry = PageStore.prototype.pageStoreJunkyard.pop(); + if ( entry ) { + return entry.init(tabContext); + } + return new PageStore(tabContext); +}; + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/js/polyfill.js b/js/polyfill.js new file mode 100644 index 0000000..dea7e39 --- /dev/null +++ b/js/polyfill.js @@ -0,0 +1,96 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2017-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix + + This file has been originally imported from: + https://github.com/gorhill/uBlock/tree/master/platform/chromium + +*/ + +// For background page or non-background pages + +/* exported objectAssign */ + +'use strict'; + +/******************************************************************************/ +/******************************************************************************/ + +// As per MDN, Object.assign appeared first in Firefox 34. +// https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Browser_compatibility + +var objectAssign = Object.assign || function(target, source) { + var keys = Object.keys(source); + for ( var i = 0, n = keys.length, key; i < n; i++ ) { + key = keys[i]; + target[key] = source[key]; + } + return target; +}; + +/******************************************************************************/ + +// Patching for Pale Moon which does not implement ES6 Set/Map. +// Test for non-ES6 Set/Map: check if property `iterator` is present. +// The code is strictly to satisfy uBO's core, not to be an accurate +// implementation of ES6. + +if ( self.Set.prototype.iterator instanceof Function ) { + //console.log('Patching non-ES6 Set() to be more ES6-like.'); + self.Set.prototype._values = self.Set.prototype.values; + self.Set.prototype.values = function() { + this._valueIter = this._values(); + this.value = undefined; + this.done = false; + return this; + }; + self.Set.prototype.next = function() { + try { + this.value = this._valueIter.next(); + } catch (ex) { + this._valueIter = undefined; + this.value = undefined; + this.done = true; + } + return this; + }; +} + +if ( self.Map.prototype.iterator instanceof Function ) { + //console.log('Patching non-ES6 Map() to be more ES6-like.'); + self.Map.prototype._entries = self.Map.prototype.entries; + self.Map.prototype.entries = function() { + this._entryIter = this._entries(); + this.value = undefined; + this.done = false; + return this; + }; + self.Map.prototype.next = function() { + try { + this.value = this._entryIter.next(); + } catch (ex) { + this._entryIter = undefined; + this.value = undefined; + this.done = true; + } + return this; + }; +} + diff --git a/js/popup.js b/js/popup.js new file mode 100644 index 0000000..98781b4 --- /dev/null +++ b/js/popup.js @@ -0,0 +1,1538 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global punycode, uDom */ +/* jshint esnext: true, bitwise: false */ + +'use strict'; + +/******************************************************************************/ +/******************************************************************************/ + +(function() { + +/******************************************************************************/ +/******************************************************************************/ + +// Stuff which is good to do very early so as to avoid visual glitches. + +(function() { + var paneContentPaddingTop = vAPI.localStorage.getItem('paneContentPaddingTop'), + touchDevice = vAPI.localStorage.getItem('touchDevice'); + + if ( typeof paneContentPaddingTop === 'string' ) { + document.querySelector('.paneContent').style.setProperty( + 'padding-top', + paneContentPaddingTop + ); + } + if ( touchDevice === 'true' ) { + document.body.setAttribute('data-touch', 'true'); + } else { + document.addEventListener('touchstart', function onTouched(ev) { + document.removeEventListener(ev.type, onTouched); + document.body.setAttribute('data-touch', 'true'); + vAPI.localStorage.setItem('touchDevice', 'true'); + resizePopup(); + }); + } +})(); + +var popupWasResized = function() { + document.body.setAttribute('data-resize-popup', ''); +}; + +var resizePopup = (function() { + var timer; + var fix = function() { + timer = undefined; + var doc = document; + // Manually adjust the position of the main matrix according to the + // height of the toolbar/matrix header. + var paddingTop = (doc.querySelector('.paneHead').clientHeight + 2) + 'px', + paneContent = doc.querySelector('.paneContent'); + if ( paddingTop !== paneContent.style.paddingTop ) { + paneContent.style.setProperty('padding-top', paddingTop); + vAPI.localStorage.setItem('paneContentPaddingTop', paddingTop); + } + document.body.classList.toggle( + 'hConstrained', + window.innerWidth < document.body.clientWidth + ); + popupWasResized(); + }; + return function() { + if ( timer !== undefined ) { + clearTimeout(timer); + } + timer = vAPI.setTimeout(fix, 97); + }; +})(); + +/******************************************************************************/ +/******************************************************************************/ + +// Must be consistent with definitions in matrix.js +var Dark = 0x80; +var Red = 1; +var Green = 2; +var DarkRed = Dark | Red; +var DarkGreen = Dark | Green; + +var matrixSnapshot = {}; +var groupsSnapshot = []; +var allHostnamesSnapshot = 'do not leave this initial string empty'; + +var matrixCellHotspots = null; + +var matrixHeaderPrettyNames = { + 'all': '', + 'cookie': '', + 'css': '', + 'image': '', + 'media': '', + 'script': '', + 'xhr': '', + 'frame': '', + 'other': '' +}; + +var firstPartyLabel = ''; +var blacklistedHostnamesLabel = ''; + +var expandosIdGenerator = 1; +var nodeToExpandosMap = (function() { + if ( typeof window.Map === 'function' ) { + return new window.Map(); + } +})(); + +var expandosFromNode = function(node) { + if ( + node instanceof HTMLElement === false && + typeof node.nodeAt === 'function' + ) { + node = node.nodeAt(0); + } + if ( nodeToExpandosMap ) { + var expandosId = node.getAttribute('data-expandos'); + if ( !expandosId ) { + expandosId = '' + (expandosIdGenerator++); + node.setAttribute('data-expandos', expandosId); + } + var expandos = nodeToExpandosMap.get(expandosId); + if ( expandos === undefined ) { + nodeToExpandosMap.set(expandosId, (expandos = Object.create(null))); + } + return expandos; + } + return node; +}; + +/******************************************************************************/ +/******************************************************************************/ + +function getUserSetting(setting) { + return matrixSnapshot.userSettings[setting]; + } + +function setUserSetting(setting, value) { + matrixSnapshot.userSettings[setting] = value; + vAPI.messaging.send('popup.js', { + what: 'userSettings', + name: setting, + value: value + }); +} + +/******************************************************************************/ + +function getUISetting(setting) { + var r = vAPI.localStorage.getItem(setting); + if ( typeof r !== 'string' ) { + return undefined; + } + return JSON.parse(r); +} + +function setUISetting(setting, value) { + vAPI.localStorage.setItem( + setting, + JSON.stringify(value) + ); +} + +/******************************************************************************/ + +function updateMatrixSnapshot() { + matrixSnapshotPoller.pollNow(); +} + +/******************************************************************************/ + +// For display purpose, create four distinct groups of rows: +// 0th: literal "1st-party" row +// 1st: page domain's related +// 2nd: whitelisted +// 3rd: graylisted +// 4th: blacklisted + +function getGroupStats() { + + // Try to not reshuffle groups around while popup is opened if + // no new hostname added. + var latestDomainListSnapshot = Object.keys(matrixSnapshot.rows).sort().join(); + if ( latestDomainListSnapshot === allHostnamesSnapshot ) { + return groupsSnapshot; + } + allHostnamesSnapshot = latestDomainListSnapshot; + + // First, group according to whether at least one node in the domain + // hierarchy is white or blacklisted + var pageDomain = matrixSnapshot.domain; + var rows = matrixSnapshot.rows; + var anyTypeOffset = matrixSnapshot.headerIndices.get('*'); + var hostname, domain; + var row, color, count, groupIndex; + var domainToGroupMap = {}; + + // These have hard-coded position which cannot be overriden + domainToGroupMap['1st-party'] = 0; + domainToGroupMap[pageDomain] = 1; + + // 1st pass: domain wins if it has an explicit rule or a count + for ( hostname in rows ) { + if ( rows.hasOwnProperty(hostname) === false ) { + continue; + } + if ( hostname === '*' || hostname === '1st-party' ) { + continue; + } + domain = rows[hostname].domain; + if ( domain === pageDomain || hostname !== domain ) { + continue; + } + row = rows[domain]; + color = row.temporary[anyTypeOffset]; + if ( color === DarkGreen ) { + domainToGroupMap[domain] = 2; + continue; + } + if ( color === DarkRed ) { + domainToGroupMap[domain] = 4; + continue; + } + count = row.counts[anyTypeOffset]; + if ( count !== 0 ) { + domainToGroupMap[domain] = 3; + continue; + } + } + // 2nd pass: green wins + for ( hostname in rows ) { + if ( rows.hasOwnProperty(hostname) === false ) { + continue; + } + row = rows[hostname]; + domain = row.domain; + if ( domainToGroupMap.hasOwnProperty(domain) ) { + continue; + } + color = row.temporary[anyTypeOffset]; + if ( color === DarkGreen ) { + domainToGroupMap[domain] = 2; + } + } + // 3rd pass: gray with count wins + for ( hostname in rows ) { + if ( rows.hasOwnProperty(hostname) === false ) { + continue; + } + row = rows[hostname]; + domain = row.domain; + if ( domainToGroupMap.hasOwnProperty(domain) ) { + continue; + } + color = row.temporary[anyTypeOffset]; + count = row.counts[anyTypeOffset]; + if ( color !== DarkRed && count !== 0 ) { + domainToGroupMap[domain] = 3; + } + } + // 4th pass: red wins whatever is left + for ( hostname in rows ) { + if ( rows.hasOwnProperty(hostname) === false ) { + continue; + } + row = rows[hostname]; + domain = row.domain; + if ( domainToGroupMap.hasOwnProperty(domain) ) { + continue; + } + color = row.temporary[anyTypeOffset]; + if ( color === DarkRed ) { + domainToGroupMap[domain] = 4; + } + } + // 5th pass: gray wins whatever is left + for ( hostname in rows ) { + if ( rows.hasOwnProperty(hostname) === false ) { + continue; + } + domain = rows[hostname].domain; + if ( domainToGroupMap.hasOwnProperty(domain) ) { + continue; + } + domainToGroupMap[domain] = 3; + } + + // Last pass: put each domain in a group + var groups = [ {}, {}, {}, {}, {} ]; + var group; + for ( hostname in rows ) { + if ( rows.hasOwnProperty(hostname) === false ) { + continue; + } + if ( hostname === '*' ) { + continue; + } + domain = rows[hostname].domain; + groupIndex = domainToGroupMap[domain]; + group = groups[groupIndex]; + if ( group.hasOwnProperty(domain) === false ) { + group[domain] = {}; + } + group[domain][hostname] = true; + } + + groupsSnapshot = groups; + + return groups; +} + +/******************************************************************************/ + +// helpers + +function getTemporaryColor(hostname, type) { + return matrixSnapshot.rows[hostname].temporary[matrixSnapshot.headerIndices.get(type)]; +} + +function getPermanentColor(hostname, type) { + return matrixSnapshot.rows[hostname].permanent[matrixSnapshot.headerIndices.get(type)]; +} + +function addCellClass(cell, hostname, type) { + var cl = cell.classList; + cl.add('matCell'); + cl.add('t' + getTemporaryColor(hostname, type).toString(16)); + cl.add('p' + getPermanentColor(hostname, type).toString(16)); +} + +/******************************************************************************/ + +// This is required for when we update the matrix while it is open: +// the user might have collapsed/expanded one or more domains, and we don't +// want to lose all his hardwork. + +function getCollapseState(domain) { + var states = getUISetting('popupCollapseSpecificDomains'); + if ( typeof states === 'object' && states[domain] !== undefined ) { + return states[domain]; + } + return matrixSnapshot.collapseAllDomains === true; +} + +function toggleCollapseState(elem) { + if ( elem.ancestors('#matHead.collapsible').length > 0 ) { + toggleMainCollapseState(elem); + } else { + toggleSpecificCollapseState(elem); + } + popupWasResized(); +} + +function toggleMainCollapseState(uelem) { + var matHead = uelem.ancestors('#matHead.collapsible').toggleClass('collapsed'); + var collapsed = matrixSnapshot.collapseAllDomains = matHead.hasClass('collapsed'); + uDom('#matList .matSection.collapsible').toggleClass('collapsed', collapsed); + setUserSetting('popupCollapseAllDomains', collapsed); + + var specificCollapseStates = getUISetting('popupCollapseSpecificDomains') || {}; + var domains = Object.keys(specificCollapseStates); + var i = domains.length; + var domain; + while ( i-- ) { + domain = domains[i]; + if ( specificCollapseStates[domain] === collapsed ) { + delete specificCollapseStates[domain]; + } + } + setUISetting('popupCollapseSpecificDomains', specificCollapseStates); +} + +function toggleSpecificCollapseState(uelem) { + // Remember collapse state forever, but only if it is different + // from main collapse switch. + var section = uelem.ancestors('.matSection.collapsible').toggleClass('collapsed'), + domain = expandosFromNode(section).domain, + collapsed = section.hasClass('collapsed'), + mainCollapseState = matrixSnapshot.collapseAllDomains === true, + specificCollapseStates = getUISetting('popupCollapseSpecificDomains') || {}; + if ( collapsed !== mainCollapseState ) { + specificCollapseStates[domain] = collapsed; + setUISetting('popupCollapseSpecificDomains', specificCollapseStates); + } else if ( specificCollapseStates[domain] !== undefined ) { + delete specificCollapseStates[domain]; + setUISetting('popupCollapseSpecificDomains', specificCollapseStates); + } +} + +/******************************************************************************/ + +// Update count value of matrix cells(s) + +function updateMatrixCounts() { + var matCells = uDom('.matrix .matRow.rw > .matCell'), + i = matCells.length, + matRow, matCell, count, counts, + headerIndices = matrixSnapshot.headerIndices, + rows = matrixSnapshot.rows, + expandos; + while ( i-- ) { + matCell = matCells.nodeAt(i); + expandos = expandosFromNode(matCell); + if ( expandos.hostname === '*' || expandos.reqType === '*' ) { + continue; + } + matRow = matCell.parentNode; + counts = matRow.classList.contains('meta') ? 'totals' : 'counts'; + count = rows[expandos.hostname][counts][headerIndices.get(expandos.reqType)]; + if ( count === expandos.count ) { continue; } + expandos.count = count; + matCell.textContent = cellTextFromCount(count); + } +} + +function cellTextFromCount(count) { + if ( count === 0 ) { return '\u00A0'; } + if ( count < 100 ) { return count; } + return '99+'; +} + +/******************************************************************************/ + +// Update color of matrix cells(s) +// Color changes when rules change + +function updateMatrixColors() { + var cells = uDom('.matrix .matRow.rw > .matCell').removeClass(), + i = cells.length, + cell, expandos; + while ( i-- ) { + cell = cells.nodeAt(i); + expandos = expandosFromNode(cell); + addCellClass(cell, expandos.hostname, expandos.reqType); + } + popupWasResized(); +} + +/******************************************************************************/ + +// Update behavior of matrix: +// - Whether a section is collapsible or not. It is collapsible if: +// - It has at least one subdomain AND +// - There is no explicit rule anywhere in the subdomain cells AND +// - It is not part of group 3 (blacklisted hostnames) + +function updateMatrixBehavior() { + matrixList = matrixList || uDom('#matList'); + var sections = matrixList.descendants('.matSection'); + var i = sections.length; + var section, subdomainRows, j, subdomainRow; + while ( i-- ) { + section = sections.at(i); + subdomainRows = section.descendants('.l2:not(.g4)'); + j = subdomainRows.length; + while ( j-- ) { + subdomainRow = subdomainRows.at(j); + subdomainRow.toggleClass('collapsible', subdomainRow.descendants('.t81,.t82').length === 0); + } + section.toggleClass('collapsible', subdomainRows.filter('.collapsible').length > 0); + } +} + +/******************************************************************************/ + +// handle user interaction with filters + +function getCellAction(hostname, type, leaning) { + var temporaryColor = getTemporaryColor(hostname, type); + var hue = temporaryColor & 0x03; + // Special case: root toggle only between two states + if ( type === '*' && hostname === '*' ) { + return hue === Green ? 'blacklistMatrixCell' : 'whitelistMatrixCell'; + } + // When explicitly blocked/allowed, can only graylist + var saturation = temporaryColor & 0x80; + if ( saturation === Dark ) { + return 'graylistMatrixCell'; + } + return leaning === 'whitelisting' ? 'whitelistMatrixCell' : 'blacklistMatrixCell'; +} + +function handleFilter(button, leaning) { + // our parent cell knows who we are + var cell = button.ancestors('div.matCell'), + expandos = expandosFromNode(cell), + type = expandos.reqType, + desHostname = expandos.hostname; + // https://github.com/gorhill/uMatrix/issues/24 + // No hostname can happen -- like with blacklist meta row + if ( desHostname === '' ) { + return; + } + var request = { + what: getCellAction(desHostname, type, leaning), + srcHostname: matrixSnapshot.scope, + desHostname: desHostname, + type: type + }; + vAPI.messaging.send('popup.js', request, updateMatrixSnapshot); +} + +function handleWhitelistFilter(button) { + handleFilter(button, 'whitelisting'); +} + +function handleBlacklistFilter(button) { + handleFilter(button, 'blacklisting'); +} + +/******************************************************************************/ + +var matrixRowPool = []; +var matrixSectionPool = []; +var matrixGroupPool = []; +var matrixRowTemplate = null; +var matrixList = null; + +var startMatrixUpdate = function() { + matrixList = matrixList || uDom('#matList'); + matrixList.detach(); + var rows = matrixList.descendants('.matRow'); + rows.detach(); + matrixRowPool = matrixRowPool.concat(rows.toArray()); + var sections = matrixList.descendants('.matSection'); + sections.detach(); + matrixSectionPool = matrixSectionPool.concat(sections.toArray()); + var groups = matrixList.descendants('.matGroup'); + groups.detach(); + matrixGroupPool = matrixGroupPool.concat(groups.toArray()); +}; + +var endMatrixUpdate = function() { + // https://github.com/gorhill/httpswitchboard/issues/246 + // If the matrix has no rows, we need to insert a dummy one, invisible, + // to ensure the extension pop-up is properly sized. This is needed because + // the header pane's `position` property is `fixed`, which means it doesn't + // affect layout size, hence the matrix header row will be truncated. + if ( matrixSnapshot.rowCount <= 1 ) { + matrixList.append(createMatrixRow().css('visibility', 'hidden')); + } + updateMatrixBehavior(); + matrixList.css('display', ''); + matrixList.appendTo('.paneContent'); +}; + +var createMatrixGroup = function() { + var group = matrixGroupPool.pop(); + if ( group ) { + return uDom(group).removeClass().addClass('matGroup'); + } + return uDom(document.createElement('div')).addClass('matGroup'); +}; + +var createMatrixSection = function() { + var section = matrixSectionPool.pop(); + if ( section ) { + return uDom(section).removeClass().addClass('matSection'); + } + return uDom(document.createElement('div')).addClass('matSection'); +}; + +var createMatrixRow = function() { + var row = matrixRowPool.pop(); + if ( row ) { + row.style.visibility = ''; + row = uDom(row); + row.descendants('.matCell').removeClass().addClass('matCell'); + row.removeClass().addClass('matRow'); + return row; + } + if ( matrixRowTemplate === null ) { + matrixRowTemplate = uDom('#templates .matRow'); + } + return matrixRowTemplate.clone(); +}; + +/******************************************************************************/ + +function renderMatrixHeaderRow() { + var matHead = uDom('#matHead.collapsible'); + matHead.toggleClass('collapsed', matrixSnapshot.collapseAllDomains === true); + var cells = matHead.descendants('.matCell'), cell, expandos; + cell = cells.nodeAt(0); + expandos = expandosFromNode(cell); + expandos.reqType = '*'; + expandos.hostname = '*'; + addCellClass(cell, '*', '*'); + cell = cells.nodeAt(1); + expandos = expandosFromNode(cell); + expandos.reqType = 'cookie'; + expandos.hostname = '*'; + addCellClass(cell, '*', 'cookie'); + cell = cells.nodeAt(2); + expandos = expandosFromNode(cell); + expandos.reqType = 'css'; + expandos.hostname = '*'; + addCellClass(cell, '*', 'css'); + cell = cells.nodeAt(3); + expandos = expandosFromNode(cell); + expandos.reqType = 'image'; + expandos.hostname = '*'; + addCellClass(cell, '*', 'image'); + cell = cells.nodeAt(4); + expandos = expandosFromNode(cell); + expandos.reqType = 'media'; + expandos.hostname = '*'; + addCellClass(cell, '*', 'media'); + cell = cells.nodeAt(5); + expandos = expandosFromNode(cell); + expandos.reqType = 'script'; + expandos.hostname = '*'; + addCellClass(cell, '*', 'script'); + cell = cells.nodeAt(6); + expandos = expandosFromNode(cell); + expandos.reqType = 'xhr'; + expandos.hostname = '*'; + addCellClass(cell, '*', 'xhr'); + cell = cells.nodeAt(7); + expandos = expandosFromNode(cell); + expandos.reqType = 'frame'; + expandos.hostname = '*'; + addCellClass(cell, '*', 'frame'); + cell = cells.nodeAt(8); + expandos = expandosFromNode(cell); + expandos.reqType = 'other'; + expandos.hostname = '*'; + addCellClass(cell, '*', 'other'); + uDom('#matHead .matRow').css('display', ''); +} + +/******************************************************************************/ + +function renderMatrixCellDomain(cell, domain) { + var expandos = expandosFromNode(cell); + expandos.hostname = domain; + expandos.reqType = '*'; + addCellClass(cell.nodeAt(0), domain, '*'); + var contents = cell.contents(); + contents.nodeAt(0).textContent = domain === '1st-party' ? + firstPartyLabel : + punycode.toUnicode(domain); + contents.nodeAt(1).textContent = ' '; +} + +function renderMatrixCellSubdomain(cell, domain, subomain) { + var expandos = expandosFromNode(cell); + expandos.hostname = subomain; + expandos.reqType = '*'; + addCellClass(cell.nodeAt(0), subomain, '*'); + var contents = cell.contents(); + contents.nodeAt(0).textContent = punycode.toUnicode(subomain.slice(0, subomain.lastIndexOf(domain)-1)) + '.'; + contents.nodeAt(1).textContent = punycode.toUnicode(domain); +} + +function renderMatrixMetaCellDomain(cell, domain) { + var expandos = expandosFromNode(cell); + expandos.hostname = domain; + expandos.reqType = '*'; + addCellClass(cell.nodeAt(0), domain, '*'); + var contents = cell.contents(); + contents.nodeAt(0).textContent = '\u2217.' + punycode.toUnicode(domain); + contents.nodeAt(1).textContent = ' '; +} + +function renderMatrixCellType(cell, hostname, type, count) { + var node = cell.nodeAt(0), + expandos = expandosFromNode(node); + expandos.hostname = hostname; + expandos.reqType = type; + expandos.count = count; + addCellClass(node, hostname, type); + node.textContent = cellTextFromCount(count); +} + +function renderMatrixCellTypes(cells, hostname, countName) { + var counts = matrixSnapshot.rows[hostname][countName]; + var headerIndices = matrixSnapshot.headerIndices; + renderMatrixCellType(cells.at(1), hostname, 'cookie', counts[headerIndices.get('cookie')]); + renderMatrixCellType(cells.at(2), hostname, 'css', counts[headerIndices.get('css')]); + renderMatrixCellType(cells.at(3), hostname, 'image', counts[headerIndices.get('image')]); + renderMatrixCellType(cells.at(4), hostname, 'media', counts[headerIndices.get('media')]); + renderMatrixCellType(cells.at(5), hostname, 'script', counts[headerIndices.get('script')]); + renderMatrixCellType(cells.at(6), hostname, 'xhr', counts[headerIndices.get('xhr')]); + renderMatrixCellType(cells.at(7), hostname, 'frame', counts[headerIndices.get('frame')]); + renderMatrixCellType(cells.at(8), hostname, 'other', counts[headerIndices.get('other')]); +} + +/******************************************************************************/ + +function makeMatrixRowDomain(domain) { + var matrixRow = createMatrixRow().addClass('rw'); + var cells = matrixRow.descendants('.matCell'); + renderMatrixCellDomain(cells.at(0), domain); + renderMatrixCellTypes(cells, domain, 'counts'); + return matrixRow; +} + +function makeMatrixRowSubdomain(domain, subdomain) { + var matrixRow = createMatrixRow().addClass('rw'); + var cells = matrixRow.descendants('.matCell'); + renderMatrixCellSubdomain(cells.at(0), domain, subdomain); + renderMatrixCellTypes(cells, subdomain, 'counts'); + return matrixRow; +} + +function makeMatrixMetaRowDomain(domain) { + var matrixRow = createMatrixRow().addClass('rw'); + var cells = matrixRow.descendants('.matCell'); + renderMatrixMetaCellDomain(cells.at(0), domain); + renderMatrixCellTypes(cells, domain, 'totals'); + return matrixRow; +} + +/******************************************************************************/ + +function renderMatrixMetaCellType(cell, count) { + // https://github.com/gorhill/uMatrix/issues/24 + // Don't forget to reset cell properties + var node = cell.nodeAt(0), + expandos = expandosFromNode(node); + expandos.hostname = ''; + expandos.reqType = ''; + expandos.count = count; + cell.addClass('t1'); + node.textContent = cellTextFromCount(count); +} + +function makeMatrixMetaRow(totals) { + var headerIndices = matrixSnapshot.headerIndices, + matrixRow = createMatrixRow().at(0).addClass('ro'), + cells = matrixRow.descendants('.matCell'), + contents = cells.at(0).addClass('t81').contents(), + expandos = expandosFromNode(cells.nodeAt(0)); + expandos.hostname = ''; + expandos.reqType = '*'; + contents.nodeAt(0).textContent = ' '; + contents.nodeAt(1).textContent = blacklistedHostnamesLabel.replace( + '{{count}}', + totals[headerIndices.get('*')].toLocaleString() + ); + renderMatrixMetaCellType(cells.at(1), totals[headerIndices.get('cookie')]); + renderMatrixMetaCellType(cells.at(2), totals[headerIndices.get('css')]); + renderMatrixMetaCellType(cells.at(3), totals[headerIndices.get('image')]); + renderMatrixMetaCellType(cells.at(4), totals[headerIndices.get('media')]); + renderMatrixMetaCellType(cells.at(5), totals[headerIndices.get('script')]); + renderMatrixMetaCellType(cells.at(6), totals[headerIndices.get('xhr')]); + renderMatrixMetaCellType(cells.at(7), totals[headerIndices.get('frame')]); + renderMatrixMetaCellType(cells.at(8), totals[headerIndices.get('other')]); + return matrixRow; +} + +/******************************************************************************/ + +function computeMatrixGroupMetaStats(group) { + var headerIndices = matrixSnapshot.headerIndices, + anyTypeIndex = headerIndices.get('*'), + n = headerIndices.size, + totals = new Array(n), + i = n; + while ( i-- ) { + totals[i] = 0; + } + var rows = matrixSnapshot.rows, row; + for ( var hostname in rows ) { + if ( rows.hasOwnProperty(hostname) === false ) { + continue; + } + row = rows[hostname]; + if ( group.hasOwnProperty(row.domain) === false ) { + continue; + } + if ( row.counts[anyTypeIndex] === 0 ) { + continue; + } + totals[0] += 1; + for ( i = 1; i < n; i++ ) { + totals[i] += row.counts[i]; + } + } + return totals; +} + +/******************************************************************************/ + +// Compare hostname helper, to order hostname in a logical manner: +// top-most < bottom-most, take into account whether IP address or +// named hostname + +function hostnameCompare(a,b) { + // Normalize: most significant parts first + if ( !a.match(/^\d+(\.\d+){1,3}$/) ) { + var aa = a.split('.'); + a = aa.slice(-2).concat(aa.slice(0,-2).reverse()).join('.'); + } + if ( !b.match(/^\d+(\.\d+){1,3}$/) ) { + var bb = b.split('.'); + b = bb.slice(-2).concat(bb.slice(0,-2).reverse()).join('.'); + } + return a.localeCompare(b); +} + +/******************************************************************************/ + +function makeMatrixGroup0SectionDomain() { + return makeMatrixRowDomain('1st-party').addClass('g0 l1'); +} + +function makeMatrixGroup0Section() { + var domainDiv = createMatrixSection(); + expandosFromNode(domainDiv).domain = '1st-party'; + makeMatrixGroup0SectionDomain().appendTo(domainDiv); + return domainDiv; +} + +function makeMatrixGroup0() { + // Show literal "1st-party" row only if there is + // at least one 1st-party hostname + if ( Object.keys(groupsSnapshot[1]).length === 0 ) { + return; + } + var groupDiv = createMatrixGroup().addClass('g0'); + makeMatrixGroup0Section().appendTo(groupDiv); + groupDiv.appendTo(matrixList); +} + +/******************************************************************************/ + +function makeMatrixGroup1SectionDomain(domain) { + return makeMatrixRowDomain(domain) + .addClass('g1 l1'); +} + +function makeMatrixGroup1SectionSubomain(domain, subdomain) { + return makeMatrixRowSubdomain(domain, subdomain) + .addClass('g1 l2'); +} + +function makeMatrixGroup1SectionMetaDomain(domain) { + return makeMatrixMetaRowDomain(domain).addClass('g1 l1 meta'); +} + +function makeMatrixGroup1Section(hostnames) { + var domain = hostnames[0]; + var domainDiv = createMatrixSection() + .toggleClass('collapsed', getCollapseState(domain)); + expandosFromNode(domainDiv).domain = domain; + if ( hostnames.length > 1 ) { + makeMatrixGroup1SectionMetaDomain(domain) + .appendTo(domainDiv); + } + makeMatrixGroup1SectionDomain(domain) + .appendTo(domainDiv); + for ( var i = 1; i < hostnames.length; i++ ) { + makeMatrixGroup1SectionSubomain(domain, hostnames[i]) + .appendTo(domainDiv); + } + return domainDiv; +} + +function makeMatrixGroup1(group) { + var domains = Object.keys(group).sort(hostnameCompare); + if ( domains.length ) { + var groupDiv = createMatrixGroup().addClass('g1'); + makeMatrixGroup1Section(Object.keys(group[domains[0]]).sort(hostnameCompare)) + .appendTo(groupDiv); + for ( var i = 1; i < domains.length; i++ ) { + makeMatrixGroup1Section(Object.keys(group[domains[i]]).sort(hostnameCompare)) + .appendTo(groupDiv); + } + groupDiv.appendTo(matrixList); + } +} + +/******************************************************************************/ + +function makeMatrixGroup2SectionDomain(domain) { + return makeMatrixRowDomain(domain) + .addClass('g2 l1'); +} + +function makeMatrixGroup2SectionSubomain(domain, subdomain) { + return makeMatrixRowSubdomain(domain, subdomain) + .addClass('g2 l2'); +} + +function makeMatrixGroup2SectionMetaDomain(domain) { + return makeMatrixMetaRowDomain(domain).addClass('g2 l1 meta'); +} + +function makeMatrixGroup2Section(hostnames) { + var domain = hostnames[0]; + var domainDiv = createMatrixSection() + .toggleClass('collapsed', getCollapseState(domain)); + expandosFromNode(domainDiv).domain = domain; + if ( hostnames.length > 1 ) { + makeMatrixGroup2SectionMetaDomain(domain).appendTo(domainDiv); + } + makeMatrixGroup2SectionDomain(domain) + .appendTo(domainDiv); + for ( var i = 1; i < hostnames.length; i++ ) { + makeMatrixGroup2SectionSubomain(domain, hostnames[i]) + .appendTo(domainDiv); + } + return domainDiv; +} + +function makeMatrixGroup2(group) { + var domains = Object.keys(group).sort(hostnameCompare); + if ( domains.length) { + var groupDiv = createMatrixGroup() + .addClass('g2'); + makeMatrixGroup2Section(Object.keys(group[domains[0]]).sort(hostnameCompare)) + .appendTo(groupDiv); + for ( var i = 1; i < domains.length; i++ ) { + makeMatrixGroup2Section(Object.keys(group[domains[i]]).sort(hostnameCompare)) + .appendTo(groupDiv); + } + groupDiv.appendTo(matrixList); + } +} + +/******************************************************************************/ + +function makeMatrixGroup3SectionDomain(domain) { + return makeMatrixRowDomain(domain) + .addClass('g3 l1'); +} + +function makeMatrixGroup3SectionSubomain(domain, subdomain) { + return makeMatrixRowSubdomain(domain, subdomain) + .addClass('g3 l2'); +} + +function makeMatrixGroup3SectionMetaDomain(domain) { + return makeMatrixMetaRowDomain(domain).addClass('g3 l1 meta'); +} + +function makeMatrixGroup3Section(hostnames) { + var domain = hostnames[0]; + var domainDiv = createMatrixSection() + .toggleClass('collapsed', getCollapseState(domain)); + expandosFromNode(domainDiv).domain = domain; + if ( hostnames.length > 1 ) { + makeMatrixGroup3SectionMetaDomain(domain).appendTo(domainDiv); + } + makeMatrixGroup3SectionDomain(domain) + .appendTo(domainDiv); + for ( var i = 1; i < hostnames.length; i++ ) { + makeMatrixGroup3SectionSubomain(domain, hostnames[i]) + .appendTo(domainDiv); + } + return domainDiv; +} + +function makeMatrixGroup3(group) { + var domains = Object.keys(group).sort(hostnameCompare); + if ( domains.length) { + var groupDiv = createMatrixGroup() + .addClass('g3'); + makeMatrixGroup3Section(Object.keys(group[domains[0]]).sort(hostnameCompare)) + .appendTo(groupDiv); + for ( var i = 1; i < domains.length; i++ ) { + makeMatrixGroup3Section(Object.keys(group[domains[i]]).sort(hostnameCompare)) + .appendTo(groupDiv); + } + groupDiv.appendTo(matrixList); + } +} + +/******************************************************************************/ + +function makeMatrixGroup4SectionDomain(domain) { + return makeMatrixRowDomain(domain) + .addClass('g4 l1'); +} + +function makeMatrixGroup4SectionSubomain(domain, subdomain) { + return makeMatrixRowSubdomain(domain, subdomain) + .addClass('g4 l2'); +} + +function makeMatrixGroup4Section(hostnames) { + var domain = hostnames[0]; + var domainDiv = createMatrixSection(); + expandosFromNode(domainDiv).domain = domain; + makeMatrixGroup4SectionDomain(domain) + .appendTo(domainDiv); + for ( var i = 1; i < hostnames.length; i++ ) { + makeMatrixGroup4SectionSubomain(domain, hostnames[i]) + .appendTo(domainDiv); + } + return domainDiv; +} + +function makeMatrixGroup4(group) { + var domains = Object.keys(group).sort(hostnameCompare); + if ( domains.length === 0 ) { + return; + } + var groupDiv = createMatrixGroup().addClass('g4'); + createMatrixSection() + .addClass('g4Meta') + .toggleClass('g4Collapsed', !!matrixSnapshot.collapseBlacklistedDomains) + .appendTo(groupDiv); + makeMatrixMetaRow(computeMatrixGroupMetaStats(group), 'g4') + .appendTo(groupDiv); + makeMatrixGroup4Section(Object.keys(group[domains[0]]).sort(hostnameCompare)) + .appendTo(groupDiv); + for ( var i = 1; i < domains.length; i++ ) { + makeMatrixGroup4Section(Object.keys(group[domains[i]]).sort(hostnameCompare)) + .appendTo(groupDiv); + } + groupDiv.appendTo(matrixList); +} + +/******************************************************************************/ + +var makeMenu = function() { + var groupStats = getGroupStats(); + + if ( Object.keys(groupStats).length === 0 ) { return; } + + // https://github.com/gorhill/httpswitchboard/issues/31 + if ( matrixCellHotspots ) { + matrixCellHotspots.detach(); + } + + renderMatrixHeaderRow(); + + startMatrixUpdate(); + makeMatrixGroup0(groupStats[0]); + makeMatrixGroup1(groupStats[1]); + makeMatrixGroup2(groupStats[2]); + makeMatrixGroup3(groupStats[3]); + makeMatrixGroup4(groupStats[4]); + endMatrixUpdate(); + + initScopeCell(); + updateMatrixButtons(); + resizePopup(); +}; + +/******************************************************************************/ + +// Do all the stuff that needs to be done before building menu et al. + +function initMenuEnvironment() { + document.body.style.setProperty( + 'font-size', + getUserSetting('displayTextSize') + ); + document.body.classList.toggle( + 'colorblind', + getUserSetting('colorBlindFriendly') + ); + uDom.nodeFromId('version').textContent = matrixSnapshot.appVersion || ''; + + var prettyNames = matrixHeaderPrettyNames; + var keys = Object.keys(prettyNames); + var i = keys.length; + var cell, key, text; + while ( i-- ) { + key = keys[i]; + cell = uDom('#matHead .matCell[data-req-type="'+ key +'"]'); + text = vAPI.i18n(key + 'PrettyName'); + cell.text(text); + prettyNames[key] = text; + } + + firstPartyLabel = uDom('[data-i18n="matrix1stPartyLabel"]').text(); + blacklistedHostnamesLabel = uDom('[data-i18n="matrixBlacklistedHostnames"]').text(); +} + +/******************************************************************************/ + +// Create page scopes for the web page + +function selectGlobalScope() { + if ( matrixSnapshot.scope === '*' ) { return; } + matrixSnapshot.scope = '*'; + document.body.classList.add('globalScope'); + matrixSnapshot.tMatrixModifiedTime = undefined; + updateMatrixSnapshot(); + dropDownMenuHide(); +} + +function selectSpecificScope(ev) { + var newScope = ev.target.getAttribute('data-scope'); + if ( !newScope || matrixSnapshot.scope === newScope ) { return; } + document.body.classList.remove('globalScope'); + matrixSnapshot.scope = newScope; + matrixSnapshot.tMatrixModifiedTime = undefined; + updateMatrixSnapshot(); + dropDownMenuHide(); +} + +function initScopeCell() { + // It's possible there is no page URL at this point: some pages cannot + // be filtered by uMatrix. + if ( matrixSnapshot.url === '' ) { return; } + var specificScope = uDom.nodeFromId('specificScope'); + + while ( specificScope.firstChild !== null ) { + specificScope.removeChild(specificScope.firstChild); + } + + // Fill in the scope menu entries + var pos = matrixSnapshot.domain.indexOf('.'); + var tld, labels; + if ( pos === -1 ) { + tld = ''; + labels = matrixSnapshot.hostname; + } else { + tld = matrixSnapshot.domain.slice(pos + 1); + labels = matrixSnapshot.hostname.slice(0, -tld.length); + } + var beg = 0, span, label; + while ( beg < labels.length ) { + pos = labels.indexOf('.', beg); + if ( pos === -1 ) { + pos = labels.length; + } else { + pos += 1; + } + label = document.createElement('span'); + label.appendChild( + document.createTextNode(punycode.toUnicode(labels.slice(beg, pos))) + ); + span = document.createElement('span'); + span.setAttribute('data-scope', labels.slice(beg) + tld); + span.appendChild(label); + specificScope.appendChild(span); + beg = pos; + } + if ( tld !== '' ) { + label = document.createElement('span'); + label.appendChild(document.createTextNode(punycode.toUnicode(tld))); + span = document.createElement('span'); + span.setAttribute('data-scope', tld); + span.appendChild(label); + specificScope.appendChild(span); + } + updateScopeCell(); +} + +function updateScopeCell() { + var specificScope = uDom.nodeFromId('specificScope'), + isGlobal = matrixSnapshot.scope === '*'; + document.body.classList.toggle('globalScope', isGlobal); + specificScope.classList.toggle('on', !isGlobal); + uDom.nodeFromId('globalScope').classList.toggle('on', isGlobal); + for ( var node of specificScope.children ) { + node.classList.toggle( + 'on', + !isGlobal && + matrixSnapshot.scope.endsWith(node.getAttribute('data-scope')) + ); + } +} + +/******************************************************************************/ + +function updateMatrixSwitches() { + var count = 0, + enabled, + switches = matrixSnapshot.tSwitches; + for ( var switchName in switches ) { + if ( switches.hasOwnProperty(switchName) === false ) { continue; } + enabled = switches[switchName]; + if ( enabled && switchName !== 'matrix-off' ) { + count += 1; + } + uDom('#mtxSwitch_' + switchName).toggleClass('switchTrue', enabled); + } + uDom.nodeFromId('mtxSwitch_https-strict').classList.toggle( + 'relevant', + matrixSnapshot.hasMixedContent + ); + uDom.nodeFromId('mtxSwitch_no-workers').classList.toggle( + 'relevant', + matrixSnapshot.hasWebWorkers + ); + uDom.nodeFromId('mtxSwitch_referrer-spoof').classList.toggle( + 'relevant', + matrixSnapshot.has3pReferrer + ); + uDom.nodeFromId('mtxSwitch_noscript-spoof').classList.toggle( + 'relevant', + matrixSnapshot.hasNoscriptTags + ); + uDom.nodeFromSelector('#buttonMtxSwitches span.badge').textContent = + count.toLocaleString(); + uDom.nodeFromSelector('#mtxSwitch_matrix-off span.badge').textContent = + matrixSnapshot.blockedCount.toLocaleString(); + document.body.classList.toggle('powerOff', switches['matrix-off']); +} + +function toggleMatrixSwitch(ev) { + if ( ev.target.localName === 'a' ) { return; } + var elem = ev.currentTarget; + var pos = elem.id.indexOf('_'); + if ( pos === -1 ) { return; } + var switchName = elem.id.slice(pos + 1); + var request = { + what: 'toggleMatrixSwitch', + switchName: switchName, + srcHostname: matrixSnapshot.scope + }; + vAPI.messaging.send('popup.js', request, updateMatrixSnapshot); +} + +/******************************************************************************/ + +function updatePersistButton() { + var diffCount = matrixSnapshot.diff.length; + var button = uDom('#buttonPersist'); + button.contents() + .filter(function(){return this.nodeType===3;}) + .first() + .text(diffCount > 0 ? '\uf13e' : '\uf023'); + button.descendants('span.badge').text(diffCount > 0 ? diffCount : ''); + var disabled = diffCount === 0; + button.toggleClass('disabled', disabled); + uDom('#buttonRevertScope').toggleClass('disabled', disabled); +} + +/******************************************************************************/ + +function persistMatrix() { + var request = { + what: 'applyDiffToPermanentMatrix', + diff: matrixSnapshot.diff + }; + vAPI.messaging.send('popup.js', request, updateMatrixSnapshot); +} + +/******************************************************************************/ + +// rhill 2014-03-12: revert completely ALL changes related to the +// current page, including scopes. + +function revertMatrix() { + var request = { + what: 'applyDiffToTemporaryMatrix', + diff: matrixSnapshot.diff + }; + vAPI.messaging.send('popup.js', request, updateMatrixSnapshot); +} + +/******************************************************************************/ + +// Buttons which are affected by any changes in the matrix + +function updateMatrixButtons() { + updateScopeCell(); + updateMatrixSwitches(); + updatePersistButton(); +} + +/******************************************************************************/ + +function revertAll() { + var request = { + what: 'revertTemporaryMatrix' + }; + vAPI.messaging.send('popup.js', request, updateMatrixSnapshot); + dropDownMenuHide(); +} + +/******************************************************************************/ + +function buttonReloadHandler(ev) { + vAPI.messaging.send('popup.js', { + what: 'forceReloadTab', + tabId: matrixSnapshot.tabId, + bypassCache: ev.ctrlKey || ev.metaKey || ev.shiftKey + }); +} + +/******************************************************************************/ + +function mouseenterMatrixCellHandler(ev) { + matrixCellHotspots.appendTo(ev.target); +} + +function mouseleaveMatrixCellHandler() { + matrixCellHotspots.detach(); +} + +/******************************************************************************/ + +function gotoExtensionURL(ev) { + var url = uDom(ev.currentTarget).attr('data-extension-url'); + if ( url ) { + vAPI.messaging.send('popup.js', { + what: 'gotoExtensionURL', + url: url, + shiftKey: ev.shiftKey + }); + } + dropDownMenuHide(); + vAPI.closePopup(); +} + +/******************************************************************************/ + +function dropDownMenuShow(ev) { + var button = ev.target; + var menuOverlay = document.getElementById(button.getAttribute('data-dropdown-menu')); + var butnRect = button.getBoundingClientRect(); + var viewRect = document.body.getBoundingClientRect(); + var butnNormalLeft = butnRect.left / (viewRect.width - butnRect.width); + menuOverlay.classList.add('show'); + var menu = menuOverlay.querySelector('.dropdown-menu'); + var menuRect = menu.getBoundingClientRect(); + var menuLeft = butnNormalLeft * (viewRect.width - menuRect.width); + menu.style.left = menuLeft.toFixed(0) + 'px'; + menu.style.top = butnRect.bottom + 'px'; +} + +function dropDownMenuHide() { + uDom('.dropdown-menu-capture').removeClass('show'); +} + +/******************************************************************************/ + +var onMatrixSnapshotReady = function(response) { + if ( response === 'ENOTFOUND' ) { + uDom.nodeFromId('noTabFound').textContent = + vAPI.i18n('matrixNoTabFound'); + document.body.classList.add('noTabFound'); + return; + } + + // Now that tabId and pageURL are set, we can build our menu + initMenuEnvironment(); + makeMenu(); + + // After popup menu is built, check whether there is a non-empty matrix + if ( matrixSnapshot.url === '' ) { + uDom('#matHead').remove(); + uDom('#toolbarContainer').remove(); + + // https://github.com/gorhill/httpswitchboard/issues/191 + uDom('#noNetTrafficPrompt').text(vAPI.i18n('matrixNoNetTrafficPrompt')); + uDom('#noNetTrafficPrompt').css('display', ''); + } + + // Create a hash to find out whether the reload button needs to be + // highlighted. + // TODO: +}; + +/******************************************************************************/ + +var matrixSnapshotPoller = (function() { + var timer = null; + + var preprocessMatrixSnapshot = function(snapshot) { + if ( Array.isArray(snapshot.headerIndices) ) { + snapshot.headerIndices = new Map(snapshot.headerIndices); + } + return snapshot; + }; + + var processPollResult = function(response) { + if ( typeof response !== 'object' ) { + return; + } + if ( + response.mtxContentModified === false && + response.mtxCountModified === false && + response.pMatrixModified === false && + response.tMatrixModified === false + ) { + return; + } + matrixSnapshot = preprocessMatrixSnapshot(response); + + if ( response.mtxContentModified ) { + makeMenu(); + return; + } + if ( response.mtxCountModified ) { + updateMatrixCounts(); + } + if ( + response.pMatrixModified || + response.tMatrixModified || + response.scopeModified + ) { + updateMatrixColors(); + updateMatrixBehavior(); + updateMatrixButtons(); + } + }; + + var onPolled = function(response) { + processPollResult(response); + pollAsync(); + }; + + var pollNow = function() { + unpollAsync(); + vAPI.messaging.send('popup.js', { + what: 'matrixSnapshot', + tabId: matrixSnapshot.tabId, + scope: matrixSnapshot.scope, + mtxContentModifiedTime: matrixSnapshot.mtxContentModifiedTime, + mtxCountModifiedTime: matrixSnapshot.mtxCountModifiedTime, + mtxDiffCount: matrixSnapshot.diff.length, + pMatrixModifiedTime: matrixSnapshot.pMatrixModifiedTime, + tMatrixModifiedTime: matrixSnapshot.tMatrixModifiedTime, + }, onPolled); + }; + + var poll = function() { + timer = null; + pollNow(); + }; + + var pollAsync = function() { + if ( timer !== null ) { + return; + } + if ( document.defaultView === null ) { + return; + } + timer = vAPI.setTimeout(poll, 1414); + }; + + var unpollAsync = function() { + if ( timer !== null ) { + clearTimeout(timer); + timer = null; + } + }; + + (function() { + var tabId = matrixSnapshot.tabId; + + // If no tab id yet, see if there is one specified in our URL + if ( tabId === undefined ) { + var matches = window.location.search.match(/(?:\?|&)tabId=([^&]+)/); + if ( matches !== null ) { + tabId = matches[1]; + // No need for logger button when embedded in logger + uDom('[data-extension-url="logger-ui.html"]').remove(); + } + } + + var snapshotFetched = function(response) { + if ( typeof response === 'object' ) { + matrixSnapshot = preprocessMatrixSnapshot(response); + } + onMatrixSnapshotReady(response); + pollAsync(); + }; + + vAPI.messaging.send('popup.js', { + what: 'matrixSnapshot', + tabId: tabId + }, snapshotFetched); + })(); + + return { + pollNow: pollNow + }; +})(); + +/******************************************************************************/ + +// Below is UI stuff which is not key to make the menu, so this can +// be done without having to wait for a tab to be bound to the menu. + +// We reuse for all cells the one and only cell hotspots. +uDom('#whitelist').on('click', function() { + handleWhitelistFilter(uDom(this)); + return false; + }); +uDom('#blacklist').on('click', function() { + handleBlacklistFilter(uDom(this)); + return false; + }); +uDom('#domainOnly').on('click', function() { + toggleCollapseState(uDom(this)); + return false; + }); +matrixCellHotspots = uDom('#cellHotspots').detach(); +uDom('body') + .on('mouseenter', '.matCell', mouseenterMatrixCellHandler) + .on('mouseleave', '.matCell', mouseleaveMatrixCellHandler); +uDom('#specificScope').on('click', selectSpecificScope); +uDom('#globalScope').on('click', selectGlobalScope); +uDom('[id^="mtxSwitch_"]').on('click', toggleMatrixSwitch); +uDom('#buttonPersist').on('click', persistMatrix); +uDom('#buttonRevertScope').on('click', revertMatrix); + +uDom('#buttonRevertAll').on('click', revertAll); +uDom('#buttonReload').on('click', buttonReloadHandler); +uDom('.extensionURL').on('click', gotoExtensionURL); + +uDom('body').on('click', '[data-dropdown-menu]', dropDownMenuShow); +uDom('body').on('click', '.dropdown-menu-capture', dropDownMenuHide); + +uDom('#matList').on('click', '.g4Meta', function(ev) { + matrixSnapshot.collapseBlacklistedDomains = + ev.target.classList.toggle('g4Collapsed'); + setUserSetting( + 'popupCollapseBlacklistedDomains', + matrixSnapshot.collapseBlacklistedDomains + ); +}); + +/******************************************************************************/ + +})(); diff --git a/js/profiler.js b/js/profiler.js new file mode 100644 index 0000000..732403c --- /dev/null +++ b/js/profiler.js @@ -0,0 +1,63 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/******************************************************************************/ + +var quickProfiler = (function() { + var timer = performance; + var time = 0; + var count = 0; + var tstart = 0; + var lastlog = timer.now(); + var prompt = ''; + var reset = function() { + time = 0; + count = 0; + tstart = 0; + }; + var avg = function() { + return count > 0 ? time / count : 0; + }; + var start = function(s) { + prompt = s || ''; + tstart = timer.now(); + }; + var stop = function(period) { + if ( period === undefined ) { + period = 10000; + } + var now = timer.now(); + count += 1; + time += (now - tstart); + if ( (now - lastlog) >= period ) { + console.log('µMatrix> %s: %s ms (%d samples)', prompt, avg().toFixed(3), count); + lastlog = now; + } + }; + return { + reset: reset, + start: start, + stop: stop + }; +})(); + +/******************************************************************************/ diff --git a/js/raw-settings.js b/js/raw-settings.js new file mode 100644 index 0000000..4abcd97 --- /dev/null +++ b/js/raw-settings.js @@ -0,0 +1,116 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2018-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uBlock +*/ + +/* global uDom */ + +'use strict'; + +/******************************************************************************/ + +(function() { + +/******************************************************************************/ + +var messaging = vAPI.messaging; +var cachedData = ''; +var rawSettingsInput = uDom.nodeFromId('rawSettings'); + +/******************************************************************************/ + +var hashFromRawSettings = function(raw) { + return raw.trim().replace(/\s+/g, '|'); +}; + +/******************************************************************************/ + +// This is to give a visual hint that the content of user blacklist has changed. + +var rawSettingsChanged = (function () { + var timer = null; + + var handler = function() { + timer = null; + var changed = + hashFromRawSettings(rawSettingsInput.value) !== cachedData; + uDom.nodeFromId('rawSettingsApply').disabled = !changed; + }; + + return function() { + if ( timer !== null ) { + clearTimeout(timer); + } + timer = vAPI.setTimeout(handler, 100); + }; +})(); + +/******************************************************************************/ + +function renderRawSettings() { + var onRead = function(raw) { + cachedData = hashFromRawSettings(raw); + var pretty = [], + whitespaces = ' ', + lines = raw.split('\n'), + max = 0, + pos, + i, n = lines.length; + for ( i = 0; i < n; i++ ) { + pos = lines[i].indexOf(' '); + if ( pos > max ) { + max = pos; + } + } + for ( i = 0; i < n; i++ ) { + pos = lines[i].indexOf(' '); + pretty.push(whitespaces.slice(0, max - pos) + lines[i]); + } + rawSettingsInput.value = pretty.join('\n') + '\n'; + rawSettingsChanged(); + rawSettingsInput.focus(); + }; + messaging.send('dashboard', { what: 'readRawSettings' }, onRead); +} + +/******************************************************************************/ + +var applyChanges = function() { + messaging.send( + 'dashboard', + { + what: 'writeRawSettings', + content: rawSettingsInput.value + }, + renderRawSettings + ); +}; + +/******************************************************************************/ + +// Handle user interaction +uDom('#rawSettings').on('input', rawSettingsChanged); +uDom('#rawSettingsApply').on('click', applyChanges); + +renderRawSettings(); + +/******************************************************************************/ + +})(); diff --git a/js/settings.js b/js/settings.js new file mode 100644 index 0000000..bc11c7a --- /dev/null +++ b/js/settings.js @@ -0,0 +1,195 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global uDom */ + +'use strict'; + +/******************************************************************************/ + +(function() { + +/******************************************************************************/ + +var cachedSettings = {}; + +/******************************************************************************/ + +function changeUserSettings(name, value) { + vAPI.messaging.send('settings.js', { + what: 'userSettings', + name: name, + value: value + }); +} + +/******************************************************************************/ + +function changeMatrixSwitch(name, state) { + vAPI.messaging.send('settings.js', { + what: 'setMatrixSwitch', + switchName: name, + state: state + }); +} + +/******************************************************************************/ + +function onChangeValueHandler(elem, setting, min, max) { + var oldVal = cachedSettings.userSettings[setting]; + var newVal = Math.round(parseFloat(elem.value)); + if ( typeof newVal !== 'number' ) { + newVal = oldVal; + } else { + newVal = Math.max(newVal, min); + newVal = Math.min(newVal, max); + } + elem.value = newVal; + if ( newVal !== oldVal ) { + changeUserSettings(setting, newVal); + } +} + +/******************************************************************************/ + +function prepareToDie() { + onChangeValueHandler( + uDom.nodeFromId('deleteUnusedSessionCookiesAfter'), + 'deleteUnusedSessionCookiesAfter', + 15, 1440 + ); + onChangeValueHandler( + uDom.nodeFromId('clearBrowserCacheAfter'), + 'clearBrowserCacheAfter', + 15, 1440 + ); +} + +/******************************************************************************/ + +function onInputChanged(ev) { + var target = ev.target; + + switch ( target.id ) { + case 'displayTextSize': + changeUserSettings('displayTextSize', target.value + 'px'); + break; + case 'clearBrowserCache': + case 'cloudStorageEnabled': + case 'collapseBlacklisted': + case 'collapseBlocked': + case 'colorBlindFriendly': + case 'deleteCookies': + case 'deleteLocalStorage': + case 'deleteUnusedSessionCookies': + case 'iconBadgeEnabled': + case 'processHyperlinkAuditing': + changeUserSettings(target.id, target.checked); + break; + case 'noMixedContent': + case 'noscriptTagsSpoofed': + case 'processReferer': + changeMatrixSwitch( + target.getAttribute('data-matrix-switch'), + target.checked + ); + break; + case 'deleteUnusedSessionCookiesAfter': + onChangeValueHandler(target, 'deleteUnusedSessionCookiesAfter', 15, 1440); + break; + case 'clearBrowserCacheAfter': + onChangeValueHandler(target, 'clearBrowserCacheAfter', 15, 1440); + break; + case 'popupScopeLevel': + changeUserSettings('popupScopeLevel', target.value); + break; + default: + break; + } + + switch ( target.id ) { + case 'collapseBlocked': + synchronizeWidgets(); + break; + default: + break; + } +} + +/******************************************************************************/ + +function synchronizeWidgets() { + var e1, e2; + + e1 = uDom.nodeFromId('collapseBlocked'); + e2 = uDom.nodeFromId('collapseBlacklisted'); + if ( e1.checked ) { + e2.setAttribute('disabled', ''); + } else { + e2.removeAttribute('disabled'); + } +} + +/******************************************************************************/ + +vAPI.messaging.send( + 'settings.js', + { what: 'getUserSettings' }, + function onSettingsReceived(settings) { + // Cache copy + cachedSettings = settings; + + var userSettings = settings.userSettings; + var matrixSwitches = settings.matrixSwitches; + + uDom('[data-setting-bool]').forEach(function(elem){ + elem.prop('checked', userSettings[elem.prop('id')] === true); + }); + + uDom('[data-matrix-switch]').forEach(function(elem){ + var switchName = elem.attr('data-matrix-switch'); + if ( typeof switchName === 'string' && switchName !== '' ) { + elem.prop('checked', matrixSwitches[switchName] === true); + } + }); + + uDom.nodeFromId('displayTextSize').value = + parseInt(userSettings.displayTextSize, 10) || 14; + + uDom.nodeFromId('popupScopeLevel').value = userSettings.popupScopeLevel; + uDom.nodeFromId('deleteUnusedSessionCookiesAfter').value = + userSettings.deleteUnusedSessionCookiesAfter; + uDom.nodeFromId('clearBrowserCacheAfter').value = + userSettings.clearBrowserCacheAfter; + + synchronizeWidgets(); + + document.addEventListener('change', onInputChanged); + + // https://github.com/gorhill/httpswitchboard/issues/197 + uDom(window).on('beforeunload', prepareToDie); + } +); + +/******************************************************************************/ + +})(); diff --git a/js/start.js b/js/start.js new file mode 100644 index 0000000..051e58f --- /dev/null +++ b/js/start.js @@ -0,0 +1,108 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +// ORDER IS IMPORTANT + +/******************************************************************************/ + +// Load everything + +(function() { + +'use strict'; + +/******************************************************************************/ + +var µm = µMatrix; + +/******************************************************************************/ + +var processCallbackQueue = function(queue, callback) { + var processOne = function() { + var fn = queue.pop(); + if ( fn ) { + fn(processOne); + } else if ( typeof callback === 'function' ) { + callback(); + } + }; + processOne(); +}; + +/******************************************************************************/ + +var onAllDone = function() { + µm.webRequest.start(); + + µm.assets.addObserver(µm.assetObserver.bind(µm)); + µm.scheduleAssetUpdater(µm.userSettings.autoUpdate ? 7 * 60 * 1000 : 0); + + vAPI.cloud.start([ 'myRulesPane' ]); +}; + +/******************************************************************************/ + +var onTabsReady = function(tabs) { + var tab; + var i = tabs.length; + // console.debug('start.js > binding %d tabs', i); + while ( i-- ) { + tab = tabs[i]; + µm.tabContextManager.push(tab.id, tab.url, 'newURL'); + } + + onAllDone(); +}; + +/******************************************************************************/ + +var onUserSettingsLoaded = function() { + µm.loadHostsFiles(); +}; + +/******************************************************************************/ + +var onPSLReady = function() { + µm.loadUserSettings(onUserSettingsLoaded); + µm.loadRawSettings(); + µm.loadMatrix(); + + // rhill 2013-11-24: bind behind-the-scene virtual tab/url manually, since the + // normal way forbid binding behind the scene tab. + // https://github.com/gorhill/httpswitchboard/issues/67 + µm.pageStores[vAPI.noTabId] = µm.pageStoreFactory(µm.tabContextManager.mustLookup(vAPI.noTabId)); + µm.pageStores[vAPI.noTabId].title = vAPI.i18n('statsPageDetailedBehindTheScenePage'); + + vAPI.tabs.getAll(onTabsReady); +}; + +/******************************************************************************/ + +processCallbackQueue(µm.onBeforeStartQueue, function() { + µm.loadPublicSuffixList(onPSLReady); +}); + +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/js/storage.js b/js/storage.js new file mode 100644 index 0000000..c2ece8f --- /dev/null +++ b/js/storage.js @@ -0,0 +1,615 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global objectAssign, punycode, publicSuffixList */ + +'use strict'; + +/******************************************************************************/ + +µMatrix.getBytesInUse = function() { + var µm = this; + var getBytesInUseHandler = function(bytesInUse) { + µm.storageUsed = bytesInUse; + }; + // Not all WebExtension implementations support getBytesInUse(). + if ( typeof vAPI.storage.getBytesInUse === 'function' ) { + vAPI.storage.getBytesInUse(null, getBytesInUseHandler); + } else { + µm.storageUsed = undefined; + } +}; + +/******************************************************************************/ + +µMatrix.saveUserSettings = function() { + this.XAL.keyvalSetMany( + this.userSettings, + this.getBytesInUse.bind(this) + ); +}; + +µMatrix.loadUserSettings = function(callback) { + var µm = this; + + if ( typeof callback !== 'function' ) { + callback = this.noopFunc; + } + + var settingsLoaded = function(store) { + // console.log('storage.js > loaded user settings'); + + µm.userSettings = store; + + callback(µm.userSettings); + }; + + vAPI.storage.get(this.userSettings, settingsLoaded); +}; + +/******************************************************************************/ + +µMatrix.loadRawSettings = function() { + var µm = this; + + var onLoaded = function(bin) { + if ( !bin || bin.rawSettings instanceof Object === false ) { return; } + for ( var key of Object.keys(bin.rawSettings) ) { + if ( + µm.rawSettings.hasOwnProperty(key) === false || + typeof bin.rawSettings[key] !== typeof µm.rawSettings[key] + ) { + continue; + } + µm.rawSettings[key] = bin.rawSettings[key]; + } + µm.rawSettingsWriteTime = Date.now(); + }; + + vAPI.storage.get('rawSettings', onLoaded); +}; + +µMatrix.saveRawSettings = function(rawSettings, callback) { + var keys = Object.keys(rawSettings); + if ( keys.length === 0 ) { + if ( typeof callback === 'function' ) { + callback(); + } + return; + } + for ( var key of keys ) { + if ( + this.rawSettingsDefault.hasOwnProperty(key) && + typeof rawSettings[key] === typeof this.rawSettingsDefault[key] + ) { + this.rawSettings[key] = rawSettings[key]; + } + } + vAPI.storage.set({ rawSettings: this.rawSettings }, callback); + this.rawSettingsWriteTime = Date.now(); +}; + +µMatrix.rawSettingsFromString = function(raw) { + var result = {}, + lineIter = new this.LineIterator(raw), + line, matches, name, value; + while ( lineIter.eot() === false ) { + line = lineIter.next().trim(); + matches = /^(\S+)(\s+(.+))?$/.exec(line); + if ( matches === null ) { continue; } + name = matches[1]; + if ( this.rawSettingsDefault.hasOwnProperty(name) === false ) { + continue; + } + value = (matches[2] || '').trim(); + switch ( typeof this.rawSettingsDefault[name] ) { + case 'boolean': + if ( value === 'true' ) { + value = true; + } else if ( value === 'false' ) { + value = false; + } else { + value = this.rawSettingsDefault[name]; + } + break; + case 'string': + if ( value === '' ) { + value = this.rawSettingsDefault[name]; + } + break; + case 'number': + value = parseInt(value, 10); + if ( isNaN(value) ) { + value = this.rawSettingsDefault[name]; + } + break; + default: + break; + } + if ( this.rawSettings[name] !== value ) { + result[name] = value; + } + } + this.saveRawSettings(result); +}; + +µMatrix.stringFromRawSettings = function() { + var out = []; + for ( var key of Object.keys(this.rawSettings).sort() ) { + out.push(key + ' ' + this.rawSettings[key]); + } + return out.join('\n'); +}; + +/******************************************************************************/ + +// save white/blacklist +µMatrix.saveMatrix = function() { + µMatrix.XAL.keyvalSetOne('userMatrix', this.pMatrix.toString()); +}; + +/******************************************************************************/ + +µMatrix.loadMatrix = function(callback) { + if ( typeof callback !== 'function' ) { + callback = this.noopFunc; + } + var µm = this; + var onLoaded = function(bin) { + if ( bin.hasOwnProperty('userMatrix') ) { + µm.pMatrix.fromString(bin.userMatrix); + µm.tMatrix.assign(µm.pMatrix); + callback(); + } + }; + this.XAL.keyvalGetOne('userMatrix', onLoaded); +}; + +/******************************************************************************/ + +µMatrix.listKeysFromCustomHostsFiles = function(raw) { + var out = new Set(), + reIgnore = /^[!#]/, + reValid = /^[a-z-]+:\/\/\S+/, + lineIter = new this.LineIterator(raw), + location; + while ( lineIter.eot() === false ) { + location = lineIter.next().trim(); + if ( reIgnore.test(location) || !reValid.test(location) ) { continue; } + out.add(location); + } + return this.setToArray(out); +}; + +/******************************************************************************/ + +µMatrix.getAvailableHostsFiles = function(callback) { + var µm = this, + availableHostsFiles = {}; + + // Custom filter lists. + var importedListKeys = this.listKeysFromCustomHostsFiles(µm.userSettings.externalHostsFiles), + i = importedListKeys.length, + listKey, entry; + while ( i-- ) { + listKey = importedListKeys[i]; + entry = { + content: 'filters', + contentURL: listKey, + external: true, + submitter: 'user', + title: listKey + }; + availableHostsFiles[listKey] = entry; + this.assets.registerAssetSource(listKey, entry); + } + + // selected lists + var onSelectedHostsFilesLoaded = function(bin) { + // Now get user's selection of lists + for ( var assetKey in bin.liveHostsFiles ) { + var availableEntry = availableHostsFiles[assetKey]; + if ( availableEntry === undefined ) { continue; } + var liveEntry = bin.liveHostsFiles[assetKey]; + availableEntry.off = liveEntry.off || false; + if ( liveEntry.entryCount !== undefined ) { + availableEntry.entryCount = liveEntry.entryCount; + } + if ( liveEntry.entryUsedCount !== undefined ) { + availableEntry.entryUsedCount = liveEntry.entryUsedCount; + } + // This may happen if the list name was pulled from the list content + if ( availableEntry.title === '' && liveEntry.title !== undefined ) { + availableEntry.title = liveEntry.title; + } + } + + // Remove unreferenced imported filter lists. + var dict = new Set(importedListKeys); + for ( assetKey in availableHostsFiles ) { + var entry = availableHostsFiles[assetKey]; + if ( entry.submitter !== 'user' ) { continue; } + if ( dict.has(assetKey) ) { continue; } + delete availableHostsFiles[assetKey]; + µm.assets.unregisterAssetSource(assetKey); + µm.assets.remove(assetKey); + } + + callback(availableHostsFiles); + }; + + // built-in lists + var onBuiltinHostsFilesLoaded = function(entries) { + for ( var assetKey in entries ) { + if ( entries.hasOwnProperty(assetKey) === false ) { continue; } + entry = entries[assetKey]; + if ( entry.content !== 'filters' ) { continue; } + availableHostsFiles[assetKey] = objectAssign({}, entry); + } + + // Now get user's selection of lists + vAPI.storage.get( + { 'liveHostsFiles': availableHostsFiles }, + onSelectedHostsFilesLoaded + ); + }; + + this.assets.metadata(onBuiltinHostsFilesLoaded); +}; + +/******************************************************************************/ + +µMatrix.loadHostsFiles = function(callback) { + var µm = µMatrix; + var hostsFileLoadCount; + + if ( typeof callback !== 'function' ) { + callback = this.noopFunc; + } + + var loadHostsFilesEnd = function() { + µm.ubiquitousBlacklist.freeze(); + vAPI.storage.set({ 'liveHostsFiles': µm.liveHostsFiles }); + vAPI.messaging.broadcast({ what: 'loadHostsFilesCompleted' }); + µm.getBytesInUse(); + callback(); + }; + + var mergeHostsFile = function(details) { + µm.mergeHostsFile(details); + hostsFileLoadCount -= 1; + if ( hostsFileLoadCount === 0 ) { + loadHostsFilesEnd(); + } + }; + + var loadHostsFilesStart = function(hostsFiles) { + µm.liveHostsFiles = hostsFiles; + µm.ubiquitousBlacklist.reset(); + var locations = Object.keys(hostsFiles); + hostsFileLoadCount = locations.length; + + // Load all hosts file which are not disabled. + var location; + while ( (location = locations.pop()) ) { + if ( hostsFiles[location].off ) { + hostsFileLoadCount -= 1; + continue; + } + µm.assets.get(location, mergeHostsFile); + } + + // https://github.com/gorhill/uMatrix/issues/2 + if ( hostsFileLoadCount === 0 ) { + loadHostsFilesEnd(); + return; + } + }; + + this.getAvailableHostsFiles(loadHostsFilesStart); +}; + +/******************************************************************************/ + +µMatrix.mergeHostsFile = function(details) { + var usedCount = this.ubiquitousBlacklist.count; + var duplicateCount = this.ubiquitousBlacklist.duplicateCount; + + this.mergeHostsFileContent(details.content); + + usedCount = this.ubiquitousBlacklist.count - usedCount; + duplicateCount = this.ubiquitousBlacklist.duplicateCount - duplicateCount; + + var hostsFilesMeta = this.liveHostsFiles[details.assetKey]; + hostsFilesMeta.entryCount = usedCount + duplicateCount; + hostsFilesMeta.entryUsedCount = usedCount; +}; + +/******************************************************************************/ + +µMatrix.mergeHostsFileContent = function(rawText) { + var rawEnd = rawText.length; + var ubiquitousBlacklist = this.ubiquitousBlacklist; + var reLocalhost = /(^|\s)(localhost\.localdomain|localhost|local|broadcasthost|0\.0\.0\.0|127\.0\.0\.1|::1|fe80::1%lo0)(?=\s|$)/g; + var reAsciiSegment = /^[\x21-\x7e]+$/; + var matches; + var lineBeg = 0, lineEnd; + var line; + + while ( lineBeg < rawEnd ) { + lineEnd = rawText.indexOf('\n', lineBeg); + if ( lineEnd < 0 ) { + lineEnd = rawText.indexOf('\r', lineBeg); + if ( lineEnd < 0 ) { + lineEnd = rawEnd; + } + } + + // rhill 2014-04-18: The trim is important here, as without it there + // could be a lingering `\r` which would cause problems in the + // following parsing code. + line = rawText.slice(lineBeg, lineEnd).trim(); + lineBeg = lineEnd + 1; + + // https://github.com/gorhill/httpswitchboard/issues/15 + // Ensure localhost et al. don't end up in the ubiquitous blacklist. + line = line + .replace(/#.*$/, '') + .toLowerCase() + .replace(reLocalhost, '') + .trim(); + + // The filter is whatever sequence of printable ascii character without + // whitespaces + matches = reAsciiSegment.exec(line); + if ( !matches || matches.length === 0 ) { + continue; + } + + // Bypass anomalies + // For example, when a filter contains whitespace characters, or + // whatever else outside the range of printable ascii characters. + if ( matches[0] !== line ) { + continue; + } + + line = matches[0]; + if ( line === '' ) { + continue; + } + + ubiquitousBlacklist.add(line); + } +}; + +/******************************************************************************/ + +// `switches` contains the filter lists for which the switch must be revisited. + +µMatrix.selectHostsFiles = function(details, callback) { + var µm = this, + externalHostsFiles = this.userSettings.externalHostsFiles, + i, n, assetKey; + + // Hosts file to select + if ( Array.isArray(details.toSelect) ) { + for ( assetKey in this.liveHostsFiles ) { + if ( this.liveHostsFiles.hasOwnProperty(assetKey) === false ) { + continue; + } + if ( details.toSelect.indexOf(assetKey) !== -1 ) { + this.liveHostsFiles[assetKey].off = false; + } else if ( details.merge !== true ) { + this.liveHostsFiles[assetKey].off = true; + } + } + } + + // Imported hosts files to remove + if ( Array.isArray(details.toRemove) ) { + var removeURLFromHaystack = function(haystack, needle) { + return haystack.replace( + new RegExp( + '(^|\\n)' + + needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + + '(\\n|$)', 'g'), + '\n' + ).trim(); + }; + for ( i = 0, n = details.toRemove.length; i < n; i++ ) { + assetKey = details.toRemove[i]; + delete this.liveHostsFiles[assetKey]; + externalHostsFiles = removeURLFromHaystack(externalHostsFiles, assetKey); + this.assets.remove(assetKey); + } + } + + // Hosts file to import + if ( typeof details.toImport === 'string' ) { + // https://github.com/gorhill/uBlock/issues/1181 + // Try mapping the URL of an imported filter list to the assetKey of an + // existing stock list. + var assetKeyFromURL = function(url) { + var needle = url.replace(/^https?:/, ''); + var assets = µm.liveHostsFiles, asset; + for ( var assetKey in assets ) { + asset = assets[assetKey]; + if ( asset.content !== 'filters' ) { continue; } + if ( typeof asset.contentURL === 'string' ) { + if ( asset.contentURL.endsWith(needle) ) { return assetKey; } + continue; + } + if ( Array.isArray(asset.contentURL) === false ) { continue; } + for ( i = 0, n = asset.contentURL.length; i < n; i++ ) { + if ( asset.contentURL[i].endsWith(needle) ) { + return assetKey; + } + } + } + return url; + }; + var importedSet = new Set(this.listKeysFromCustomHostsFiles(externalHostsFiles)), + toImportSet = new Set(this.listKeysFromCustomHostsFiles(details.toImport)), + iter = toImportSet.values(); + for (;;) { + var entry = iter.next(); + if ( entry.done ) { break; } + if ( importedSet.has(entry.value) ) { continue; } + assetKey = assetKeyFromURL(entry.value); + if ( assetKey === entry.value ) { + importedSet.add(entry.value); + } + this.liveHostsFiles[assetKey] = { + content: 'filters', + contentURL: [ assetKey ], + title: assetKey + }; + } + externalHostsFiles = this.setToArray(importedSet).sort().join('\n'); + } + + if ( externalHostsFiles !== this.userSettings.externalHostsFiles ) { + this.userSettings.externalHostsFiles = externalHostsFiles; + vAPI.storage.set({ externalHostsFiles: externalHostsFiles }); + } + vAPI.storage.set({ 'liveHostsFiles': this.liveHostsFiles }); + + if ( typeof callback === 'function' ) { + callback(); + } +}; + +/******************************************************************************/ + +// `switches` contains the preset blacklists for which the switch must be +// revisited. + +µMatrix.reloadHostsFiles = function() { + this.loadHostsFiles(); +}; + +/******************************************************************************/ + +µMatrix.loadPublicSuffixList = function(callback) { + if ( typeof callback !== 'function' ) { + callback = this.noopFunc; + } + + var applyPublicSuffixList = function(details) { + if ( !details.error ) { + publicSuffixList.parse(details.content, punycode.toASCII); + } + callback(); + }; + + this.assets.get(this.pslAssetKey, applyPublicSuffixList); +}; + +/******************************************************************************/ + +µMatrix.scheduleAssetUpdater = (function() { + var timer, next = 0; + return function(updateDelay) { + if ( timer ) { + clearTimeout(timer); + timer = undefined; + } + if ( updateDelay === 0 ) { + next = 0; + return; + } + var now = Date.now(); + // Use the new schedule if and only if it is earlier than the previous + // one. + if ( next !== 0 ) { + updateDelay = Math.min(updateDelay, Math.max(next - now, 0)); + } + next = now + updateDelay; + timer = vAPI.setTimeout(function() { + timer = undefined; + next = 0; + µMatrix.assets.updateStart({ delay: 120000 }); + }, updateDelay); + }; +})(); + +/******************************************************************************/ + +µMatrix.assetObserver = function(topic, details) { + // Do not update filter list if not in use. + if ( topic === 'before-asset-updated' ) { + if ( + this.liveHostsFiles.hasOwnProperty(details.assetKey) === false || + this.liveHostsFiles[details.assetKey].off === true + ) { + return false; + } + return; + } + + if ( topic === 'after-asset-updated' ) { + vAPI.messaging.broadcast({ + what: 'assetUpdated', + key: details.assetKey, + cached: true + }); + return; + } + + // Update failed. + if ( topic === 'asset-update-failed' ) { + vAPI.messaging.broadcast({ + what: 'assetUpdated', + key: details.assetKey, + failed: true + }); + return; + } + + // Reload all filter lists if needed. + if ( topic === 'after-assets-updated' ) { + if ( details.assetKeys.length !== 0 ) { + this.loadHostsFiles(); + } + if ( this.userSettings.autoUpdate ) { + this.scheduleAssetUpdater(25200000); + } else { + this.scheduleAssetUpdater(0); + } + vAPI.messaging.broadcast({ + what: 'assetsUpdated', + assetKeys: details.assetKeys + }); + return; + } + + // New asset source became available, if it's a filter list, should we + // auto-select it? + if ( topic === 'builtin-asset-source-added' ) { + if ( details.entry.content === 'filters' ) { + if ( details.entry.off !== true ) { + this.saveSelectedFilterLists([ details.assetKey ], true); + } + } + return; + } +}; diff --git a/js/tab.js b/js/tab.js new file mode 100644 index 0000000..b0dd1ab --- /dev/null +++ b/js/tab.js @@ -0,0 +1,710 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/******************************************************************************/ +/******************************************************************************/ + +(function() { + +'use strict'; + +/******************************************************************************/ + +var µm = µMatrix; + +// https://github.com/gorhill/httpswitchboard/issues/303 +// Some kind of trick going on here: +// Any scheme other than 'http' and 'https' is remapped into a fake +// URL which trick the rest of µMatrix into being able to process an +// otherwise unmanageable scheme. µMatrix needs web page to have a proper +// hostname to work properly, so just like the 'behind-the-scene' +// fake domain name, we map unknown schemes into a fake '{scheme}-scheme' +// hostname. This way, for a specific scheme you can create scope with +// rules which will apply only to that scheme. + +/******************************************************************************/ +/******************************************************************************/ + +µm.normalizePageURL = function(tabId, pageURL) { + if ( vAPI.isBehindTheSceneTabId(tabId) ) { + return 'http://' + this.behindTheSceneScope + '/'; + } + + // If the URL is that of our "blocked page" document, return the URL of + // the blocked page. + if ( pageURL.lastIndexOf(vAPI.getURL('main-blocked.html'), 0) === 0 ) { + var matches = /main-blocked\.html\?details=([^&]+)/.exec(pageURL); + if ( matches && matches.length === 2 ) { + try { + var details = JSON.parse(atob(matches[1])); + pageURL = details.url; + } catch (e) { + } + } + } + + var uri = this.URI.set(pageURL); + var scheme = uri.scheme; + if ( scheme === 'https' || scheme === 'http' ) { + return uri.normalizedURI(); + } + + var fakeHostname = scheme + '-scheme'; + + if ( uri.hostname !== '' ) { + fakeHostname = uri.hostname + '.' + fakeHostname; + } else if ( scheme === 'about' ) { + fakeHostname = uri.path + '.' + fakeHostname; + } + + return 'http://' + fakeHostname + '/'; +}; + +/******************************************************************************/ +/****************************************************************************** + +To keep track from which context *exactly* network requests are made. This is +often tricky for various reasons, and the challenge is not specific to one +browser. + +The time at which a URL is assigned to a tab and the time when a network +request for a root document is made must be assumed to be unrelated: it's all +asynchronous. There is no guaranteed order in which the two events are fired. + +Also, other "anomalies" can occur: + +- a network request for a root document is fired without the corresponding +tab being really assigned a new URL +<https://github.com/chrisaljoudi/uBlock/issues/516> + +- a network request for a secondary resource is labeled with a tab id for +which no root document was pulled for that tab. +<https://github.com/chrisaljoudi/uBlock/issues/1001> + +- a network request for a secondary resource is made without the root +document to which it belongs being formally bound yet to the proper tab id, +causing a bad scope to be used for filtering purpose. +<https://github.com/chrisaljoudi/uBlock/issues/1205> +<https://github.com/chrisaljoudi/uBlock/issues/1140> + +So the solution here is to keep a lightweight data structure which only +purpose is to keep track as accurately as possible of which root document +belongs to which tab. That's the only purpose, and because of this, there are +no restrictions for when the URL of a root document can be associated to a tab. + +Before, the PageStore object was trying to deal with this, but it had to +enforce some restrictions so as to not descend into one of the above issues, or +other issues. The PageStore object can only be associated with a tab for which +a definitive navigation event occurred, because it collects information about +what occurred in the tab (for example, the number of requests blocked for a +page). + +The TabContext objects do not suffer this restriction, and as a result they +offer the most reliable picture of which root document URL is really associated +to which tab. Moreover, the TabObject can undo an association from a root +document, and automatically re-associate with the next most recent. This takes +care of <https://github.com/chrisaljoudi/uBlock/issues/516>. + +The PageStore object no longer cache the various information about which +root document it is currently bound. When it needs to find out, it will always +defer to the TabContext object, which will provide the real answer. This takes +case of <https://github.com/chrisaljoudi/uBlock/issues/1205>. In effect, the +master switch and dynamic filtering rules can be evaluated now properly even +in the absence of a PageStore object, this was not the case before. + +Also, the TabContext object will try its best to find a good candidate root +document URL for when none exists. This takes care of +<https://github.com/chrisaljoudi/uBlock/issues/1001>. + +The TabContext manager is self-contained, and it takes care to properly +housekeep itself. + +*/ + +µm.tabContextManager = (function() { + var tabContexts = Object.create(null); + + // https://github.com/chrisaljoudi/uBlock/issues/1001 + // This is to be used as last-resort fallback in case a tab is found to not + // be bound while network requests are fired for the tab. + var mostRecentRootDocURL = ''; + var mostRecentRootDocURLTimestamp = 0; + + var gcPeriod = 31 * 60 * 1000; // every 31 minutes + + // A pushed entry is removed from the stack unless it is committed with + // a set time. + var StackEntry = function(url, commit) { + this.url = url; + this.committed = commit; + this.tstamp = Date.now(); + }; + + var TabContext = function(tabId) { + this.tabId = tabId; + this.stack = []; + this.rawURL = + this.normalURL = + this.scheme = + this.rootHostname = + this.rootDomain = ''; + this.secure = false; + this.commitTimer = null; + this.gcTimer = null; + + tabContexts[tabId] = this; + }; + + TabContext.prototype.destroy = function() { + if ( vAPI.isBehindTheSceneTabId(this.tabId) ) { + return; + } + if ( this.gcTimer !== null ) { + clearTimeout(this.gcTimer); + this.gcTimer = null; + } + delete tabContexts[this.tabId]; + }; + + TabContext.prototype.onTab = function(tab) { + if ( tab ) { + this.gcTimer = vAPI.setTimeout(this.onGC.bind(this), gcPeriod); + } else { + this.destroy(); + } + }; + + TabContext.prototype.onGC = function() { + this.gcTimer = null; + if ( vAPI.isBehindTheSceneTabId(this.tabId) ) { + return; + } + vAPI.tabs.get(this.tabId, this.onTab.bind(this)); + }; + + // https://github.com/gorhill/uBlock/issues/248 + // Stack entries have to be committed to stick. Non-committed stack + // entries are removed after a set delay. + TabContext.prototype.onCommit = function() { + if ( vAPI.isBehindTheSceneTabId(this.tabId) ) { + return; + } + this.commitTimer = null; + // Remove uncommitted entries at the top of the stack. + var i = this.stack.length; + while ( i-- ) { + if ( this.stack[i].committed ) { + break; + } + } + // https://github.com/gorhill/uBlock/issues/300 + // If no committed entry was found, fall back on the bottom-most one + // as being the committed one by default. + if ( i === -1 && this.stack.length !== 0 ) { + this.stack[0].committed = true; + i = 0; + } + i += 1; + if ( i < this.stack.length ) { + this.stack.length = i; + this.update(); + µm.bindTabToPageStats(this.tabId, 'newURL'); + } + }; + + // This takes care of orphanized tab contexts. Can't be started for all + // contexts, as the behind-the-scene context is permanent -- so we do not + // want to flush it. + TabContext.prototype.autodestroy = function() { + if ( vAPI.isBehindTheSceneTabId(this.tabId) ) { + return; + } + this.gcTimer = vAPI.setTimeout(this.onGC.bind(this), gcPeriod); + }; + + // Update just force all properties to be updated to match the most recent + // root URL. + TabContext.prototype.update = function() { + if ( this.stack.length === 0 ) { + this.rawURL = this.normalURL = this.scheme = + this.rootHostname = this.rootDomain = ''; + this.secure = false; + return; + } + this.rawURL = this.stack[this.stack.length - 1].url; + this.normalURL = µm.normalizePageURL(this.tabId, this.rawURL); + this.scheme = µm.URI.schemeFromURI(this.rawURL); + this.rootHostname = µm.URI.hostnameFromURI(this.normalURL); + this.rootDomain = µm.URI.domainFromHostname(this.rootHostname) || this.rootHostname; + this.secure = µm.URI.isSecureScheme(this.scheme); + }; + + // Called whenever a candidate root URL is spotted for the tab. + TabContext.prototype.push = function(url, context) { + if ( vAPI.isBehindTheSceneTabId(this.tabId) ) { return; } + var committed = context !== undefined; + var count = this.stack.length; + var topEntry = this.stack[count - 1]; + if ( topEntry && topEntry.url === url ) { + if ( committed ) { + topEntry.committed = true; + } + return; + } + if ( this.commitTimer !== null ) { + clearTimeout(this.commitTimer); + } + if ( committed ) { + this.stack = [new StackEntry(url, true)]; + } else { + this.stack.push(new StackEntry(url)); + this.commitTimer = vAPI.setTimeout(this.onCommit.bind(this), 1000); + } + this.update(); + µm.bindTabToPageStats(this.tabId, context); + }; + + // These are to be used for the API of the tab context manager. + + var push = function(tabId, url, context) { + var entry = tabContexts[tabId]; + if ( entry === undefined ) { + entry = new TabContext(tabId); + entry.autodestroy(); + } + entry.push(url, context); + mostRecentRootDocURL = url; + mostRecentRootDocURLTimestamp = Date.now(); + return entry; + }; + + // Find a tab context for a specific tab. If none is found, attempt to + // fix this. When all fail, the behind-the-scene context is returned. + var mustLookup = function(tabId, url) { + var entry; + if ( url !== undefined ) { + entry = push(tabId, url); + } else { + entry = tabContexts[tabId]; + } + if ( entry !== undefined ) { + return entry; + } + // https://github.com/chrisaljoudi/uBlock/issues/1025 + // Google Hangout popup opens without a root frame. So for now we will + // just discard that best-guess root frame if it is too far in the + // future, at which point it ceases to be a "best guess". + if ( mostRecentRootDocURL !== '' && mostRecentRootDocURLTimestamp + 500 < Date.now() ) { + mostRecentRootDocURL = ''; + } + // https://github.com/chrisaljoudi/uBlock/issues/1001 + // Not a behind-the-scene request, yet no page store found for the + // tab id: we will thus bind the last-seen root document to the + // unbound tab. It's a guess, but better than ending up filtering + // nothing at all. + if ( mostRecentRootDocURL !== '' ) { + return push(tabId, mostRecentRootDocURL); + } + // If all else fail at finding a page store, re-categorize the + // request as behind-the-scene. At least this ensures that ultimately + // the user can still inspect/filter those net requests which were + // about to fall through the cracks. + // Example: Chromium + case #12 at + // http://raymondhill.net/ublock/popup.html + return tabContexts[vAPI.noTabId]; + }; + + var lookup = function(tabId) { + return tabContexts[tabId] || null; + }; + + // Behind-the-scene tab context + (function() { + var entry = new TabContext(vAPI.noTabId); + entry.stack.push(new StackEntry('', true)); + entry.rawURL = ''; + entry.normalURL = µm.normalizePageURL(entry.tabId); + entry.rootHostname = µm.URI.hostnameFromURI(entry.normalURL); + entry.rootDomain = µm.URI.domainFromHostname(entry.rootHostname) || entry.rootHostname; + })(); + + // https://github.com/gorhill/uMatrix/issues/513 + // Force a badge update here, it could happen that all the subsequent + // network requests are already in the page store, which would cause + // the badge to no be updated for these network requests. + + vAPI.tabs.onNavigation = function(details) { + var tabId = details.tabId; + if ( vAPI.isBehindTheSceneTabId(tabId) ) { return; } + push(tabId, details.url, 'newURL'); + µm.updateBadgeAsync(tabId); + }; + + // https://github.com/gorhill/uMatrix/issues/872 + // `changeInfo.url` may not always be available (Firefox). + + vAPI.tabs.onUpdated = function(tabId, changeInfo, tab) { + if ( vAPI.isBehindTheSceneTabId(tabId) ) { return; } + if ( typeof tab.url !== 'string' || tab.url === '' ) { return; } + var url = changeInfo.url || tab.url; + if ( url ) { + push(tabId, url, 'updateURL'); + } + }; + + vAPI.tabs.onClosed = function(tabId) { + µm.unbindTabFromPageStats(tabId); + var entry = tabContexts[tabId]; + if ( entry instanceof TabContext ) { + entry.destroy(); + } + }; + + return { + push: push, + lookup: lookup, + mustLookup: mustLookup + }; +})(); + +vAPI.tabs.registerListeners(); + +/******************************************************************************/ +/******************************************************************************/ + +// Create an entry for the tab if it doesn't exist + +µm.bindTabToPageStats = function(tabId, context) { + this.updateBadgeAsync(tabId); + + // Do not create a page store for URLs which are of no interests + // Example: dev console + var tabContext = this.tabContextManager.lookup(tabId); + if ( tabContext === null ) { + throw new Error('Unmanaged tab id: ' + tabId); + } + + // rhill 2013-11-24: Never ever rebind behind-the-scene + // virtual tab. + // https://github.com/gorhill/httpswitchboard/issues/67 + if ( vAPI.isBehindTheSceneTabId(tabId) ) { + return this.pageStores[tabId]; + } + + var normalURL = tabContext.normalURL; + var pageStore = this.pageStores[tabId] || null; + + // The previous page URL, if any, associated with the tab + if ( pageStore !== null ) { + // No change, do not rebind + if ( pageStore.pageUrl === normalURL ) { + return pageStore; + } + + // https://github.com/gorhill/uMatrix/issues/37 + // Just rebind whenever possible: the URL changed, but the document + // maybe is the same. + // Example: Google Maps, Github + // https://github.com/gorhill/uMatrix/issues/72 + // Need to double-check that the new scope is same as old scope + if ( context === 'updateURL' && pageStore.pageHostname === tabContext.rootHostname ) { + pageStore.rawURL = tabContext.rawURL; + pageStore.normalURL = normalURL; + this.updateTitle(tabId); + this.pageStoresToken = Date.now(); + return pageStore; + } + + // We won't be reusing this page store. + this.unbindTabFromPageStats(tabId); + } + + // Try to resurrect first. + pageStore = this.resurrectPageStore(tabId, normalURL); + if ( pageStore === null ) { + pageStore = this.pageStoreFactory(tabContext); + } + this.pageStores[tabId] = pageStore; + this.updateTitle(tabId); + this.pageStoresToken = Date.now(); + + // console.debug('tab.js > bindTabToPageStats(): dispatching traffic in tab id %d to page store "%s"', tabId, pageUrl); + + return pageStore; +}; + +/******************************************************************************/ + +µm.unbindTabFromPageStats = function(tabId) { + // Never unbind behind-the-scene page store. + if ( vAPI.isBehindTheSceneTabId(tabId) ) { + return; + } + + var pageStore = this.pageStores[tabId] || null; + if ( pageStore === null ) { + return; + } + + delete this.pageStores[tabId]; + this.pageStoresToken = Date.now(); + + if ( pageStore.incinerationTimer ) { + clearTimeout(pageStore.incinerationTimer); + pageStore.incinerationTimer = null; + } + + if ( this.pageStoreCemetery.hasOwnProperty(tabId) === false ) { + this.pageStoreCemetery[tabId] = {}; + } + var pageStoreCrypt = this.pageStoreCemetery[tabId]; + + var pageURL = pageStore.pageUrl; + pageStoreCrypt[pageURL] = pageStore; + + pageStore.incinerationTimer = vAPI.setTimeout( + this.incineratePageStore.bind(this, tabId, pageURL), + 4 * 60 * 1000 + ); +}; + +/******************************************************************************/ + +µm.resurrectPageStore = function(tabId, pageURL) { + if ( this.pageStoreCemetery.hasOwnProperty(tabId) === false ) { + return null; + } + var pageStoreCrypt = this.pageStoreCemetery[tabId]; + + if ( pageStoreCrypt.hasOwnProperty(pageURL) === false ) { + return null; + } + + var pageStore = pageStoreCrypt[pageURL]; + + if ( pageStore.incinerationTimer !== null ) { + clearTimeout(pageStore.incinerationTimer); + pageStore.incinerationTimer = null; + } + + delete pageStoreCrypt[pageURL]; + if ( Object.keys(pageStoreCrypt).length === 0 ) { + delete this.pageStoreCemetery[tabId]; + } + + return pageStore; +}; + +/******************************************************************************/ + +µm.incineratePageStore = function(tabId, pageURL) { + if ( this.pageStoreCemetery.hasOwnProperty(tabId) === false ) { + return; + } + var pageStoreCrypt = this.pageStoreCemetery[tabId]; + + if ( pageStoreCrypt.hasOwnProperty(pageURL) === false ) { + return; + } + + var pageStore = pageStoreCrypt[pageURL]; + if ( pageStore.incinerationTimer !== null ) { + clearTimeout(pageStore.incinerationTimer); + pageStore.incinerationTimer = null; + } + + delete pageStoreCrypt[pageURL]; + if ( Object.keys(pageStoreCrypt).length === 0 ) { + delete this.pageStoreCemetery[tabId]; + } + + pageStore.dispose(); +}; + +/******************************************************************************/ + +µm.pageStoreFromTabId = function(tabId) { + return this.pageStores[tabId] || null; +}; + +// Never return null +µm.mustPageStoreFromTabId = function(tabId) { + return this.pageStores[tabId] || this.pageStores[vAPI.noTabId]; +}; + +/******************************************************************************/ + +µm.forceReload = function(tabId, bypassCache) { + vAPI.tabs.reload(tabId, bypassCache); +}; + +/******************************************************************************/ + +// Update badge + +// rhill 2013-11-09: well this sucks, I can't update icon/badge +// incrementally, as chromium overwrite the icon at some point without +// notifying me, and this causes internal cached state to be out of sync. + +µm.updateBadgeAsync = (function() { + var tabIdToTimer = Object.create(null); + + var updateBadge = function(tabId) { + delete tabIdToTimer[tabId]; + + var iconId = null; + var badgeStr = ''; + + var pageStore = this.pageStoreFromTabId(tabId); + if ( pageStore !== null ) { + var total = pageStore.perLoadAllowedRequestCount + + pageStore.perLoadBlockedRequestCount; + if ( total ) { + var squareSize = 19; + var greenSize = squareSize * Math.sqrt(pageStore.perLoadAllowedRequestCount / total); + iconId = greenSize < squareSize/2 ? Math.ceil(greenSize) : Math.floor(greenSize); + } + if ( this.userSettings.iconBadgeEnabled && pageStore.distinctRequestCount !== 0) { + badgeStr = this.formatCount(pageStore.distinctRequestCount); + } + } + + vAPI.setIcon(tabId, iconId, badgeStr); + }; + + return function(tabId) { + if ( tabIdToTimer[tabId] ) { + return; + } + if ( vAPI.isBehindTheSceneTabId(tabId) ) { + return; + } + tabIdToTimer[tabId] = vAPI.setTimeout(updateBadge.bind(this, tabId), 750); + }; +})(); + +/******************************************************************************/ + +µm.updateTitle = (function() { + var tabIdToTimer = Object.create(null); + var tabIdToTryCount = Object.create(null); + var delay = 499; + + var tryNoMore = function(tabId) { + delete tabIdToTryCount[tabId]; + }; + + var tryAgain = function(tabId) { + var count = tabIdToTryCount[tabId]; + if ( count === undefined ) { + return false; + } + if ( count === 1 ) { + delete tabIdToTryCount[tabId]; + return false; + } + tabIdToTryCount[tabId] = count - 1; + tabIdToTimer[tabId] = vAPI.setTimeout(updateTitle.bind(µm, tabId), delay); + return true; + }; + + var onTabReady = function(tabId, tab) { + if ( !tab ) { + return tryNoMore(tabId); + } + var pageStore = this.pageStoreFromTabId(tabId); + if ( pageStore === null ) { + return tryNoMore(tabId); + } + if ( !tab.title && tryAgain(tabId) ) { + return; + } + // https://github.com/gorhill/uMatrix/issues/225 + // Sometimes title changes while page is loading. + var settled = tab.title && tab.title === pageStore.title; + pageStore.title = tab.title || tab.url || ''; + this.pageStoresToken = Date.now(); + if ( settled || !tryAgain(tabId) ) { + tryNoMore(tabId); + } + }; + + var updateTitle = function(tabId) { + delete tabIdToTimer[tabId]; + vAPI.tabs.get(tabId, onTabReady.bind(this, tabId)); + }; + + return function(tabId) { + if ( vAPI.isBehindTheSceneTabId(tabId) ) { + return; + } + if ( tabIdToTimer[tabId] ) { + clearTimeout(tabIdToTimer[tabId]); + } + tabIdToTimer[tabId] = vAPI.setTimeout(updateTitle.bind(this, tabId), delay); + tabIdToTryCount[tabId] = 5; + }; +})(); + +/******************************************************************************/ + +// Stale page store entries janitor +// https://github.com/chrisaljoudi/uBlock/issues/455 + +(function() { + var cleanupPeriod = 7 * 60 * 1000; + var cleanupSampleAt = 0; + var cleanupSampleSize = 11; + + var cleanup = function() { + var vapiTabs = vAPI.tabs; + var tabIds = Object.keys(µm.pageStores).sort(); + var checkTab = function(tabId) { + vapiTabs.get(tabId, function(tab) { + if ( !tab ) { + µm.unbindTabFromPageStats(tabId); + } + }); + }; + if ( cleanupSampleAt >= tabIds.length ) { + cleanupSampleAt = 0; + } + var tabId; + var n = Math.min(cleanupSampleAt + cleanupSampleSize, tabIds.length); + for ( var i = cleanupSampleAt; i < n; i++ ) { + tabId = tabIds[i]; + if ( vAPI.isBehindTheSceneTabId(tabId) ) { + continue; + } + checkTab(tabId); + } + cleanupSampleAt = n; + + vAPI.setTimeout(cleanup, cleanupPeriod); + }; + + vAPI.setTimeout(cleanup, cleanupPeriod); +})(); + +/******************************************************************************/ + +})(); diff --git a/js/traffic.js b/js/traffic.js new file mode 100644 index 0000000..3e6a6ac --- /dev/null +++ b/js/traffic.js @@ -0,0 +1,444 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +'use strict'; + +/******************************************************************************/ + +// Start isolation from global scope + +µMatrix.webRequest = (function() { + +/******************************************************************************/ + +// Intercept and filter web requests according to white and black lists. + +var onBeforeRootFrameRequestHandler = function(details) { + var µm = µMatrix; + var requestURL = details.url; + var requestHostname = µm.URI.hostnameFromURI(requestURL); + var tabId = details.tabId; + + µm.tabContextManager.push(tabId, requestURL); + + var tabContext = µm.tabContextManager.mustLookup(tabId); + var rootHostname = tabContext.rootHostname; + + // Disallow request as per matrix? + var block = µm.mustBlock(rootHostname, requestHostname, 'doc'); + + var pageStore = µm.pageStoreFromTabId(tabId); + pageStore.recordRequest('doc', requestURL, block); + µm.logger.writeOne(tabId, 'net', rootHostname, requestURL, 'doc', block); + + // Not blocked + if ( !block ) { + // rhill 2013-11-07: Senseless to do this for behind-the-scene requests. + µm.cookieHunter.recordPageCookies(pageStore); + return; + } + + // Blocked + var query = btoa(JSON.stringify({ + url: requestURL, + hn: requestHostname, + why: '?' + })); + + vAPI.tabs.replace(tabId, vAPI.getURL('main-blocked.html?details=') + query); + + return { cancel: true }; +}; + +/******************************************************************************/ + +// Intercept and filter web requests according to white and black lists. + +var onBeforeRequestHandler = function(details) { + var µm = µMatrix, + µmuri = µm.URI, + requestURL = details.url, + requestScheme = µmuri.schemeFromURI(requestURL); + + if ( µmuri.isNetworkScheme(requestScheme) === false ) { return; } + + var requestType = requestTypeNormalizer[details.type] || 'other'; + + // https://github.com/gorhill/httpswitchboard/issues/303 + // Wherever the main doc comes from, create a receiver page URL: synthetize + // one if needed. + if ( requestType === 'doc' && details.parentFrameId === -1 ) { + return onBeforeRootFrameRequestHandler(details); + } + + // Re-classify orphan HTTP requests as behind-the-scene requests. There is + // not much else which can be done, because there are URLs + // which cannot be handled by µMatrix, i.e. `opera://startpage`, + // as this would lead to complications with no obvious solution, like how + // to scope on unknown scheme? Etc. + // https://github.com/gorhill/httpswitchboard/issues/191 + // https://github.com/gorhill/httpswitchboard/issues/91#issuecomment-37180275 + var tabContext = µm.tabContextManager.mustLookup(details.tabId), + tabId = tabContext.tabId, + rootHostname = tabContext.rootHostname, + specificity = 0; + + // Filter through matrix + var block = µm.tMatrix.mustBlock( + rootHostname, + µmuri.hostnameFromURI(requestURL), + requestType + ); + if ( block ) { + specificity = µm.tMatrix.specificityRegister; + } + + // Record request. + // https://github.com/gorhill/httpswitchboard/issues/342 + // The way requests are handled now, it may happen at this point some + // processing has already been performed, and that a synthetic URL has + // been constructed for logging purpose. Use this synthetic URL if + // it is available. + var pageStore = µm.mustPageStoreFromTabId(tabId); + + // Enforce strict secure connection? + if ( tabContext.secure && µmuri.isSecureScheme(requestScheme) === false ) { + pageStore.hasMixedContent = true; + if ( block === false ) { + block = µm.tMatrix.evaluateSwitchZ('https-strict', rootHostname); + } + } + + pageStore.recordRequest(requestType, requestURL, block); + µm.logger.writeOne(tabId, 'net', rootHostname, requestURL, details.type, block); + + if ( block ) { + pageStore.cacheBlockedCollapsible(requestType, requestURL, specificity); + return { 'cancel': true }; + } +}; + +/******************************************************************************/ + +// Sanitize outgoing headers as per user settings. + +var onBeforeSendHeadersHandler = function(details) { + var µm = µMatrix, + µmuri = µm.URI, + requestURL = details.url, + requestScheme = µmuri.schemeFromURI(requestURL); + + // Ignore non-network schemes + if ( µmuri.isNetworkScheme(requestScheme) === false ) { return; } + + // Re-classify orphan HTTP requests as behind-the-scene requests. There is + // not much else which can be done, because there are URLs + // which cannot be handled by HTTP Switchboard, i.e. `opera://startpage`, + // as this would lead to complications with no obvious solution, like how + // to scope on unknown scheme? Etc. + // https://github.com/gorhill/httpswitchboard/issues/191 + // https://github.com/gorhill/httpswitchboard/issues/91#issuecomment-37180275 + var tabId = details.tabId, + pageStore = µm.mustPageStoreFromTabId(tabId), + requestType = requestTypeNormalizer[details.type] || 'other', + requestHeaders = details.requestHeaders, + headerIndex, headerValue; + + // https://github.com/gorhill/httpswitchboard/issues/342 + // Is this hyperlink auditing? + // If yes, create a synthetic URL for reporting hyperlink auditing + // in request log. This way the user is better informed of what went + // on. + + // https://html.spec.whatwg.org/multipage/semantics.html#hyperlink-auditing + // + // Target URL = the href of the link + // Doc URL = URL of the document containing the target URL + // Ping URLs = servers which will be told that user clicked target URL + // + // `Content-Type` = `text/ping` (always present) + // `Ping-To` = target URL (always present) + // `Ping-From` = doc URL + // `Referer` = doc URL + // request URL = URL which will receive the information + // + // With hyperlink-auditing, removing header(s) is pointless, the whole + // request must be cancelled. + + headerIndex = headerIndexFromName('ping-to', requestHeaders); + if ( headerIndex !== -1 ) { + headerValue = requestHeaders[headerIndex].value; + if ( headerValue !== '' ) { + var block = µm.userSettings.processHyperlinkAuditing; + pageStore.recordRequest('other', requestURL + '{Ping-To:' + headerValue + '}', block); + µm.logger.writeOne(tabId, 'net', '', requestURL, 'ping', block); + if ( block ) { + µm.hyperlinkAuditingFoiledCounter += 1; + return { 'cancel': true }; + } + } + } + + // If we reach this point, request is not blocked, so what is left to do + // is to sanitize headers. + + var rootHostname = pageStore.pageHostname, + requestHostname = µmuri.hostnameFromURI(requestURL), + modified = false; + + // Process `Cookie` header. + + headerIndex = headerIndexFromName('cookie', requestHeaders); + if ( + headerIndex !== -1 && + µm.mustBlock(rootHostname, requestHostname, 'cookie') + ) { + modified = true; + headerValue = requestHeaders[headerIndex].value; + requestHeaders.splice(headerIndex, 1); + µm.cookieHeaderFoiledCounter++; + if ( requestType === 'doc' ) { + µm.logger.writeOne(tabId, 'net', '', headerValue, 'COOKIE', true); + } + } + + // Process `Referer` header. + + // https://github.com/gorhill/httpswitchboard/issues/222#issuecomment-44828402 + + // https://github.com/gorhill/uMatrix/issues/320 + // http://tools.ietf.org/html/rfc6454#section-7.3 + // "The user agent MAY include an Origin header field in any HTTP + // "request. + // "The user agent MUST NOT include more than one Origin header field in + // "any HTTP request. + // "Whenever a user agent issues an HTTP request from a "privacy- + // "sensitive" context, the user agent MUST send the value "null" in the + // "Origin header field." + + // https://github.com/gorhill/uMatrix/issues/358 + // Do not spoof `Origin` header for the time being. + + // https://github.com/gorhill/uMatrix/issues/773 + // For non-GET requests, remove `Referer` header instead of spoofing it. + + headerIndex = headerIndexFromName('referer', requestHeaders); + if ( headerIndex !== -1 ) { + headerValue = requestHeaders[headerIndex].value; + if ( headerValue !== '' ) { + var toDomain = µmuri.domainFromHostname(requestHostname); + if ( toDomain !== '' && toDomain !== µmuri.domainFromURI(headerValue) ) { + pageStore.has3pReferrer = true; + if ( µm.tMatrix.evaluateSwitchZ('referrer-spoof', rootHostname) ) { + modified = true; + var newValue; + if ( details.method === 'GET' ) { + newValue = requestHeaders[headerIndex].value = + requestScheme + '://' + requestHostname + '/'; + } else { + requestHeaders.splice(headerIndex, 1); + } + µm.refererHeaderFoiledCounter++; + if ( requestType === 'doc' ) { + µm.logger.writeOne(tabId, 'net', '', headerValue, 'REFERER', true); + if ( newValue !== undefined ) { + µm.logger.writeOne(tabId, 'net', '', newValue, 'REFERER', false); + } + } + } + } + } + } + + if ( modified ) { + return { requestHeaders: requestHeaders }; + } +}; + +/******************************************************************************/ + +// To prevent inline javascript from being executed. + +// Prevent inline scripting using `Content-Security-Policy`: +// https://dvcs.w3.org/hg/content-security-policy/raw-file/tip/csp-specification.dev.html + +// This fixes: +// https://github.com/gorhill/httpswitchboard/issues/35 + +var onHeadersReceived = function(details) { + // Ignore schemes other than 'http...' + var µm = µMatrix, + tabId = details.tabId, + requestURL = details.url, + requestType = requestTypeNormalizer[details.type] || 'other'; + + // https://github.com/gorhill/uMatrix/issues/145 + // Check if the main_frame is a download + if ( requestType === 'doc' ) { + µm.tabContextManager.push(tabId, requestURL); + } + + var tabContext = µm.tabContextManager.lookup(tabId); + if ( tabContext === null ) { return; } + + var csp = [], + cspReport = [], + rootHostname = tabContext.rootHostname, + requestHostname = µm.URI.hostnameFromURI(requestURL); + + // Inline script tags. + if ( µm.mustAllow(rootHostname, requestHostname, 'script' ) !== true ) { + csp.push(µm.cspNoInlineScript); + } + + // Inline style tags. + if ( µm.mustAllow(rootHostname, requestHostname, 'css' ) !== true ) { + csp.push(µm.cspNoInlineStyle); + } + + // https://bugzilla.mozilla.org/show_bug.cgi?id=1302667 + var cspNoWorker = µm.cspNoWorker; + if ( cspNoWorker === undefined ) { + cspNoWorker = cspNoWorkerInit(); + } + + if ( µm.tMatrix.evaluateSwitchZ('no-workers', rootHostname) ) { + csp.push(cspNoWorker); + } else if ( µm.rawSettings.disableCSPReportInjection === false ) { + cspReport.push(cspNoWorker); + } + + var headers = details.responseHeaders, + cspDirectives, i; + + if ( csp.length !== 0 ) { + cspDirectives = csp.join(','); + i = headerIndexFromName('content-security-policy', headers); + if ( i !== -1 ) { + headers[i].value += ',' + cspDirectives; + } else { + headers.push({ + name: 'Content-Security-Policy', + value: cspDirectives + }); + } + if ( requestType === 'doc' ) { + µm.logger.writeOne(tabId, 'net', '', cspDirectives, 'CSP', false); + } + } + + if ( cspReport.length !== 0 ) { + cspDirectives = cspReport.join(','); + i = headerIndexFromName('content-security-policy-report-only', headers); + if ( i !== -1 ) { + headers[i].value += ',' + cspDirectives; + } else { + headers.push({ + name: 'Content-Security-Policy-Report-Only', + value: cspDirectives + }); + } + } + + return { responseHeaders: headers }; +}; + +/******************************************************************************/ + +var cspNoWorkerInit = function() { + if ( vAPI.webextFlavor === undefined ) { + return "child-src 'none'; frame-src data: blob: *; report-uri about:blank"; + } + µMatrix.cspNoWorker = /^Mozilla-Firefox-5[67]/.test(vAPI.webextFlavor) ? + "child-src 'none'; frame-src data: blob: *; report-uri about:blank" : + "worker-src 'none'; report-uri about:blank" ; + return µMatrix.cspNoWorker; +}; + +/******************************************************************************/ + +// Caller must ensure headerName is normalized to lower case. + +var headerIndexFromName = function(headerName, headers) { + var i = headers.length; + while ( i-- ) { + if ( headers[i].name.toLowerCase() === headerName ) { + return i; + } + } + return -1; +}; + +/******************************************************************************/ + +var requestTypeNormalizer = { + 'font' : 'css', + 'image' : 'image', + 'imageset' : 'image', + 'main_frame' : 'doc', + 'media' : 'media', + 'object' : 'media', + 'other' : 'other', + 'script' : 'script', + 'stylesheet' : 'css', + 'sub_frame' : 'frame', + 'websocket' : 'xhr', + 'xmlhttprequest': 'xhr' +}; + +/******************************************************************************/ + +vAPI.net.onBeforeRequest = { + extra: [ 'blocking' ], + callback: onBeforeRequestHandler +}; + +vAPI.net.onBeforeSendHeaders = { + extra: [ 'blocking', 'requestHeaders' ], + callback: onBeforeSendHeadersHandler +}; + +vAPI.net.onHeadersReceived = { + urls: [ 'http://*/*', 'https://*/*' ], + types: [ 'main_frame', 'sub_frame' ], + extra: [ 'blocking', 'responseHeaders' ], + callback: onHeadersReceived +}; + +/******************************************************************************/ + +var start = function() { + vAPI.net.registerListeners(); +}; + +/******************************************************************************/ + +return { + start: start +}; + +/******************************************************************************/ + +})(); + +/******************************************************************************/ + diff --git a/js/udom.js b/js/udom.js new file mode 100644 index 0000000..19848aa --- /dev/null +++ b/js/udom.js @@ -0,0 +1,729 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uBlock +*/ + +/* global DOMTokenList */ +/* exported uDom */ + +'use strict'; + +/******************************************************************************/ + +// It's just a silly, minimalist DOM framework: this allows me to not rely +// on jQuery. jQuery contains way too much stuff than I need, and as per +// Opera rules, I am not allowed to use a cut-down version of jQuery. So +// the code here does *only* what I need, and nothing more, and with a lot +// of assumption on passed parameters, etc. I grow it on a per-need-basis only. + +var uDom = (function() { + +/******************************************************************************/ + +var DOMList = function() { + this.nodes = []; +}; + +/******************************************************************************/ + +Object.defineProperty( + DOMList.prototype, + 'length', + { + get: function() { + return this.nodes.length; + } + } +); + +/******************************************************************************/ + +var DOMListFactory = function(selector, context) { + var r = new DOMList(); + if ( typeof selector === 'string' ) { + selector = selector.trim(); + if ( selector !== '' ) { + return addSelectorToList(r, selector, context); + } + } + if ( selector instanceof Node ) { + return addNodeToList(r, selector); + } + if ( selector instanceof NodeList ) { + return addNodeListToList(r, selector); + } + if ( selector instanceof DOMList ) { + return addListToList(r, selector); + } + return r; +}; + +/******************************************************************************/ + +DOMListFactory.onLoad = function(callback) { + window.addEventListener('load', callback); +}; + +/******************************************************************************/ + +DOMListFactory.nodeFromId = function(id) { + return document.getElementById(id); +}; + +DOMListFactory.nodeFromSelector = function(selector) { + return document.querySelector(selector); +}; + +/******************************************************************************/ + +var addNodeToList = function(list, node) { + if ( node ) { + list.nodes.push(node); + } + return list; +}; + +/******************************************************************************/ + +var addNodeListToList = function(list, nodelist) { + if ( nodelist ) { + var n = nodelist.length; + for ( var i = 0; i < n; i++ ) { + list.nodes.push(nodelist[i]); + } + } + return list; +}; + +/******************************************************************************/ + +var addListToList = function(list, other) { + list.nodes = list.nodes.concat(other.nodes); + return list; +}; + +/******************************************************************************/ + +var addSelectorToList = function(list, selector, context) { + var p = context || document; + var r = p.querySelectorAll(selector); + var n = r.length; + for ( var i = 0; i < n; i++ ) { + list.nodes.push(r[i]); + } + return list; +}; + +/******************************************************************************/ + +var nodeInNodeList = function(node, nodeList) { + var i = nodeList.length; + while ( i-- ) { + if ( nodeList[i] === node ) { + return true; + } + } + return false; +}; + +/******************************************************************************/ + +var doesMatchSelector = function(node, selector) { + if ( !node ) { + return false; + } + if ( node.nodeType !== 1 ) { + return false; + } + if ( selector === undefined ) { + return true; + } + var parentNode = node.parentNode; + if ( !parentNode || !parentNode.setAttribute ) { + return false; + } + var doesMatch = false; + parentNode.setAttribute('uDom-32kXc6xEZA7o73AMB8vLbLct1RZOkeoO', ''); + var grandpaNode = parentNode.parentNode || document; + var nl = grandpaNode.querySelectorAll('[uDom-32kXc6xEZA7o73AMB8vLbLct1RZOkeoO] > ' + selector); + var i = nl.length; + while ( doesMatch === false && i-- ) { + doesMatch = nl[i] === node; + } + parentNode.removeAttribute('uDom-32kXc6xEZA7o73AMB8vLbLct1RZOkeoO'); + return doesMatch; +}; + +/******************************************************************************/ + +DOMList.prototype.nodeAt = function(i) { + return this.nodes[i] || null; +}; + +DOMList.prototype.at = function(i) { + return addNodeToList(new DOMList(), this.nodes[i]); +}; + +/******************************************************************************/ + +DOMList.prototype.toArray = function() { + return this.nodes.slice(); +}; + +/******************************************************************************/ + +DOMList.prototype.pop = function() { + return addNodeToList(new DOMList(), this.nodes.pop()); +}; + +/******************************************************************************/ + +DOMList.prototype.forEach = function(fn) { + var n = this.nodes.length; + for ( var i = 0; i < n; i++ ) { + fn(this.at(i), i); + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.subset = function(i, l) { + var r = new DOMList(); + var n = l !== undefined ? l : this.nodes.length; + var j = Math.min(i + n, this.nodes.length); + if ( i < j ) { + r.nodes = this.nodes.slice(i, j); + } + return r; +}; + +/******************************************************************************/ + +DOMList.prototype.first = function() { + return this.subset(0, 1); +}; + +/******************************************************************************/ + +DOMList.prototype.next = function(selector) { + var r = new DOMList(); + var n = this.nodes.length; + var node; + for ( var i = 0; i < n; i++ ) { + node = this.nodes[i]; + while ( node.nextSibling !== null ) { + node = node.nextSibling; + if ( node.nodeType !== 1 ) { + continue; + } + if ( doesMatchSelector(node, selector) === false ) { + continue; + } + addNodeToList(r, node); + break; + } + } + return r; +}; + +/******************************************************************************/ + +DOMList.prototype.parent = function() { + var r = new DOMList(); + if ( this.nodes.length ) { + addNodeToList(r, this.nodes[0].parentNode); + } + return r; +}; + +/******************************************************************************/ + +DOMList.prototype.filter = function(filter) { + var r = new DOMList(); + var filterFunc; + if ( typeof filter === 'string' ) { + filterFunc = function() { + return doesMatchSelector(this, filter); + }; + } else if ( typeof filter === 'function' ) { + filterFunc = filter; + } else { + filterFunc = function(){ + return true; + }; + } + var n = this.nodes.length; + var node; + for ( var i = 0; i < n; i++ ) { + node = this.nodes[i]; + if ( filterFunc.apply(node) ) { + addNodeToList(r, node); + } + } + return r; +}; + +/******************************************************************************/ + +// TODO: Avoid possible duplicates + +DOMList.prototype.ancestors = function(selector) { + var r = new DOMList(); + var n = this.nodes.length; + var node; + for ( var i = 0; i < n; i++ ) { + node = this.nodes[i].parentNode; + while ( node ) { + if ( doesMatchSelector(node, selector) ) { + addNodeToList(r, node); + } + node = node.parentNode; + } + } + return r; +}; + +/******************************************************************************/ + +DOMList.prototype.descendants = function(selector) { + var r = new DOMList(); + var n = this.nodes.length; + var nl; + for ( var i = 0; i < n; i++ ) { + nl = this.nodes[i].querySelectorAll(selector); + addNodeListToList(r, nl); + } + return r; +}; + +/******************************************************************************/ + +DOMList.prototype.contents = function() { + var r = new DOMList(); + var cnodes, cn, ci; + var n = this.nodes.length; + for ( var i = 0; i < n; i++ ) { + cnodes = this.nodes[i].childNodes; + cn = cnodes.length; + for ( ci = 0; ci < cn; ci++ ) { + addNodeToList(r, cnodes.item(ci)); + } + } + return r; +}; + +/******************************************************************************/ + +DOMList.prototype.remove = function() { + var cn, p; + var i = this.nodes.length; + while ( i-- ) { + cn = this.nodes[i]; + if ( (p = cn.parentNode) ) { + p.removeChild(cn); + } + } + return this; +}; + +DOMList.prototype.detach = DOMList.prototype.remove; + +/******************************************************************************/ + +DOMList.prototype.empty = function() { + var node; + var i = this.nodes.length; + while ( i-- ) { + node = this.nodes[i]; + while ( node.firstChild ) { + node.removeChild(node.firstChild); + } + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.append = function(selector, context) { + var p = this.nodes[0]; + if ( p ) { + var c = DOMListFactory(selector, context); + var n = c.nodes.length; + for ( var i = 0; i < n; i++ ) { + p.appendChild(c.nodes[i]); + } + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.prepend = function(selector, context) { + var p = this.nodes[0]; + if ( p ) { + var c = DOMListFactory(selector, context); + var i = c.nodes.length; + while ( i-- ) { + p.insertBefore(c.nodes[i], p.firstChild); + } + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.appendTo = function(selector, context) { + var p = selector instanceof DOMListFactory ? selector : DOMListFactory(selector, context); + var n = p.length; + for ( var i = 0; i < n; i++ ) { + p.nodes[0].appendChild(this.nodes[i]); + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.insertAfter = function(selector, context) { + if ( this.nodes.length === 0 ) { + return this; + } + var p = this.nodes[0].parentNode; + if ( !p ) { + return this; + } + var c = DOMListFactory(selector, context); + var n = c.nodes.length; + for ( var i = 0; i < n; i++ ) { + p.appendChild(c.nodes[i]); + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.insertBefore = function(selector, context) { + if ( this.nodes.length === 0 ) { + return this; + } + var referenceNodes = DOMListFactory(selector, context); + if ( referenceNodes.nodes.length === 0 ) { + return this; + } + var referenceNode = referenceNodes.nodes[0]; + var parentNode = referenceNode.parentNode; + if ( !parentNode ) { + return this; + } + var n = this.nodes.length; + for ( var i = 0; i < n; i++ ) { + parentNode.insertBefore(this.nodes[i], referenceNode); + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.clone = function(notDeep) { + var r = new DOMList(); + var n = this.nodes.length; + for ( var i = 0; i < n; i++ ) { + addNodeToList(r, this.nodes[i].cloneNode(!notDeep)); + } + return r; +}; + +/******************************************************************************/ + +DOMList.prototype.nthOfType = function() { + if ( this.nodes.length === 0 ) { + return 0; + } + var node = this.nodes[0]; + var tagName = node.tagName; + var i = 1; + while ( node.previousElementSibling !== null ) { + node = node.previousElementSibling; + if ( typeof node.tagName !== 'string' ) { + continue; + } + if ( node.tagName !== tagName ) { + continue; + } + i++; + } + return i; +}; + +/******************************************************************************/ + +DOMList.prototype.attr = function(attr, value) { + var i = this.nodes.length; + if ( value === undefined && typeof attr !== 'object' ) { + return i ? this.nodes[0].getAttribute(attr) : undefined; + } + if ( typeof attr === 'object' ) { + var attrNames = Object.keys(attr); + var node, j, attrName; + while ( i-- ) { + node = this.nodes[i]; + j = attrNames.length; + while ( j-- ) { + attrName = attrNames[j]; + node.setAttribute(attrName, attr[attrName]); + } + } + } else { + while ( i-- ) { + this.nodes[i].setAttribute(attr, value); + } + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.prop = function(prop, value) { + var i = this.nodes.length; + if ( value === undefined ) { + return i !== 0 ? this.nodes[0][prop] : undefined; + } + while ( i-- ) { + this.nodes[i][prop] = value; + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.css = function(prop, value) { + var i = this.nodes.length; + if ( value === undefined ) { + return i ? this.nodes[0].style[prop] : undefined; + } + if ( value !== '' ) { + while ( i-- ) { + this.nodes[i].style.setProperty(prop, value); + } + return this; + } + while ( i-- ) { + this.nodes[i].style.removeProperty(prop); + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.val = function(value) { + return this.prop('value', value); +}; + +/******************************************************************************/ + +DOMList.prototype.html = function(html) { + var i = this.nodes.length; + if ( html === undefined ) { + return i ? this.nodes[0].innerHTML : ''; + } + while ( i-- ) { + vAPI.insertHTML(this.nodes[i], html); + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.text = function(text) { + var i = this.nodes.length; + if ( text === undefined ) { + return i ? this.nodes[0].textContent : ''; + } + while ( i-- ) { + this.nodes[i].textContent = text; + } + return this; +}; + +/******************************************************************************/ + +var toggleClass = function(node, className, targetState) { + var tokenList = node.classList; + if ( tokenList instanceof DOMTokenList === false ) { + return; + } + var currentState = tokenList.contains(className); + var newState = targetState; + if ( newState === undefined ) { + newState = !currentState; + } + if ( newState === currentState ) { + return; + } + tokenList.toggle(className, newState); +}; + +/******************************************************************************/ + +DOMList.prototype.hasClass = function(className) { + if ( !this.nodes.length ) { + return false; + } + var tokenList = this.nodes[0].classList; + return tokenList instanceof DOMTokenList && + tokenList.contains(className); +}; +DOMList.prototype.hasClassName = DOMList.prototype.hasClass; + +DOMList.prototype.addClass = function(className) { + return this.toggleClass(className, true); +}; + +DOMList.prototype.removeClass = function(className) { + if ( className !== undefined ) { + return this.toggleClass(className, false); + } + var i = this.nodes.length; + while ( i-- ) { + this.nodes[i].className = ''; + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.toggleClass = function(className, targetState) { + if ( className.indexOf(' ') !== -1 ) { + return this.toggleClasses(className, targetState); + } + var i = this.nodes.length; + while ( i-- ) { + toggleClass(this.nodes[i], className, targetState); + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.toggleClasses = function(classNames, targetState) { + var tokens = classNames.split(/\s+/); + var i = this.nodes.length; + var node, j; + while ( i-- ) { + node = this.nodes[i]; + j = tokens.length; + while ( j-- ) { + toggleClass(node, tokens[j], targetState); + } + } + return this; +}; + +/******************************************************************************/ + +var listenerEntries = []; + +var ListenerEntry = function(target, type, capture, callback) { + this.target = target; + this.type = type; + this.capture = capture; + this.callback = callback; + target.addEventListener(type, callback, capture); +}; + +ListenerEntry.prototype.dispose = function() { + this.target.removeEventListener(this.type, this.callback, this.capture); + this.target = null; + this.callback = null; +}; + +/******************************************************************************/ + +var makeEventHandler = function(selector, callback) { + return function(event) { + var dispatcher = event.currentTarget; + if ( !dispatcher || typeof dispatcher.querySelectorAll !== 'function' ) { + return; + } + var receiver = event.target; + if ( nodeInNodeList(receiver, dispatcher.querySelectorAll(selector)) ) { + callback.call(receiver, event); + } + }; +}; + +DOMList.prototype.on = function(etype, selector, callback) { + if ( typeof selector === 'function' ) { + callback = selector; + selector = undefined; + } else { + callback = makeEventHandler(selector, callback); + } + + var i = this.nodes.length; + while ( i-- ) { + listenerEntries.push(new ListenerEntry(this.nodes[i], etype, selector !== undefined, callback)); + } + return this; +}; + +/******************************************************************************/ + +// TODO: Won't work for delegated handlers. Need to figure +// what needs to be done. + +DOMList.prototype.off = function(evtype, callback) { + var i = this.nodes.length; + while ( i-- ) { + this.nodes[i].removeEventListener(evtype, callback); + } + return this; +}; + +/******************************************************************************/ + +DOMList.prototype.trigger = function(etype) { + var ev = new CustomEvent(etype); + var i = this.nodes.length; + while ( i-- ) { + this.nodes[i].dispatchEvent(ev); + } + return this; +}; + +/******************************************************************************/ + +// Cleanup + +var onBeforeUnload = function() { + var entry; + while ( (entry = listenerEntries.pop()) ) { + entry.dispose(); + } + window.removeEventListener('beforeunload', onBeforeUnload); +}; + +window.addEventListener('beforeunload', onBeforeUnload); + +/******************************************************************************/ + +return DOMListFactory; + +})(); diff --git a/js/uritools.js b/js/uritools.js new file mode 100644 index 0000000..2e50fd2 --- /dev/null +++ b/js/uritools.js @@ -0,0 +1,537 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global publicSuffixList, punycode */ + +'use strict'; + +/******************************************************************************* + +RFC 3986 as reference: http://tools.ietf.org/html/rfc3986#appendix-A + +Naming convention from https://en.wikipedia.org/wiki/URI_scheme#Examples + +*/ + +/******************************************************************************/ + +µMatrix.URI = (function() { + +/******************************************************************************/ + +// Favorite regex tool: http://regex101.com/ + +// Ref: <http://tools.ietf.org/html/rfc3986#page-50> +// I removed redundant capture groups: capture less = peform faster. See +// <http://jsperf.com/old-uritools-vs-new-uritools> +// Performance improvements welcomed. +// jsperf: <http://jsperf.com/old-uritools-vs-new-uritools> +var reRFC3986 = /^([^:\/?#]+:)?(\/\/[^\/?#]*)?([^?#]*)(\?[^#]*)?(#.*)?/; + +// Derived +var reSchemeFromURI = /^[^:\/?#]+:/; +var reAuthorityFromURI = /^(?:[^:\/?#]+:)?(\/\/[^\/?#]+)/; +var reOriginFromURI = /^(?:[^:\/?#]+:)?(?:\/\/[^\/?#]+)/; +var reCommonHostnameFromURL = /^https?:\/\/([0-9a-z_][0-9a-z._-]*[0-9a-z])\//; +var rePathFromURI = /^(?:[^:\/?#]+:)?(?:\/\/[^\/?#]*)?([^?#]*)/; +var reMustNormalizeHostname = /[^0-9a-z._-]/; + +// These are to parse authority field, not parsed by above official regex +// IPv6 is seen as an exception: a non-compatible IPv6 is first tried, and +// if it fails, the IPv6 compatible regex istr used. This helps +// peformance by avoiding the use of a too complicated regex first. + +// https://github.com/gorhill/httpswitchboard/issues/211 +// "While a hostname may not contain other characters, such as the +// "underscore character (_), other DNS names may contain the underscore" +var reHostPortFromAuthority = /^(?:[^@]*@)?([^:]*)(:\d*)?$/; +var reIPv6PortFromAuthority = /^(?:[^@]*@)?(\[[0-9a-f:]*\])(:\d*)?$/i; + +var reHostFromNakedAuthority = /^[0-9a-z._-]+[0-9a-z]$/i; +var reHostFromAuthority = /^(?:[^@]*@)?([^:]+)(?::\d*)?$/; +var reIPv6FromAuthority = /^(?:[^@]*@)?(\[[0-9a-f:]+\])(?::\d*)?$/i; + +// Coarse (but fast) tests +var reValidHostname = /^([a-z\d]+(-*[a-z\d]+)*)(\.[a-z\d]+(-*[a-z\d])*)*$/; +var reIPAddressNaive = /^\d+\.\d+\.\d+\.\d+$|^\[[\da-zA-Z:]+\]$/; + +// Accurate tests +// Source.: http://stackoverflow.com/questions/5284147/validating-ipv4-addresses-with-regexp/5284410#5284410 +//var reIPv4 = /^((25[0-5]|2[0-4]\d|[01]?\d\d?)(\.|$)){4}/; + +// Source: http://forums.intermapper.com/viewtopic.php?p=1096#1096 +//var reIPv6 = /^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$/; + +/******************************************************************************/ + +var reset = function(o) { + o.scheme = ''; + o.hostname = ''; + o._ipv4 = undefined; + o._ipv6 = undefined; + o.port = ''; + o.path = ''; + o.query = ''; + o.fragment = ''; + return o; +}; + +var resetAuthority = function(o) { + o.hostname = ''; + o._ipv4 = undefined; + o._ipv6 = undefined; + o.port = ''; + return o; +}; + +/******************************************************************************/ + +// This will be exported + +var URI = { + scheme: '', + authority: '', + hostname: '', + _ipv4: undefined, + _ipv6: undefined, + port: '', + domain: undefined, + path: '', + query: '', + fragment: '', + schemeBit: (1 << 0), + userBit: (1 << 1), + passwordBit: (1 << 2), + hostnameBit: (1 << 3), + portBit: (1 << 4), + pathBit: (1 << 5), + queryBit: (1 << 6), + fragmentBit: (1 << 7), + allBits: (0xFFFF) +}; + +URI.authorityBit = (URI.userBit | URI.passwordBit | URI.hostnameBit | URI.portBit); +URI.normalizeBits = (URI.schemeBit | URI.hostnameBit | URI.pathBit | URI.queryBit); + +/******************************************************************************/ + +// See: https://en.wikipedia.org/wiki/URI_scheme#Examples +// URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] +// +// foo://example.com:8042/over/there?name=ferret#nose +// \_/ \______________/\_________/ \_________/ \__/ +// | | | | | +// scheme authority path query fragment +// | _____________________|__ +// / \ / \ +// urn:example:animal:ferret:nose + +URI.set = function(uri) { + if ( uri === undefined ) { + return reset(URI); + } + var matches = reRFC3986.exec(uri); + if ( !matches ) { + return reset(URI); + } + this.scheme = matches[1] !== undefined ? matches[1].slice(0, -1) : ''; + this.authority = matches[2] !== undefined ? matches[2].slice(2).toLowerCase() : ''; + this.path = matches[3] !== undefined ? matches[3] : ''; + + // <http://tools.ietf.org/html/rfc3986#section-6.2.3> + // "In general, a URI that uses the generic syntax for authority + // "with an empty path should be normalized to a path of '/'." + if ( this.authority !== '' && this.path === '' ) { + this.path = '/'; + } + this.query = matches[4] !== undefined ? matches[4].slice(1) : ''; + this.fragment = matches[5] !== undefined ? matches[5].slice(1) : ''; + + // Assume very simple authority, i.e. just a hostname (highest likelihood + // case for µMatrix) + if ( reHostFromNakedAuthority.test(this.authority) ) { + this.hostname = this.authority; + this.port = ''; + return this; + } + // Authority contains more than just a hostname + matches = reHostPortFromAuthority.exec(this.authority); + if ( !matches ) { + matches = reIPv6PortFromAuthority.exec(this.authority); + if ( !matches ) { + return resetAuthority(URI); + } + } + this.hostname = matches[1] !== undefined ? matches[1] : ''; + // http://en.wikipedia.org/wiki/FQDN + if ( this.hostname.slice(-1) === '.' ) { + this.hostname = this.hostname.slice(0, -1); + } + this.port = matches[2] !== undefined ? matches[2].slice(1) : ''; + return this; +}; + +/******************************************************************************/ + +// URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] +// +// foo://example.com:8042/over/there?name=ferret#nose +// \_/ \______________/\_________/ \_________/ \__/ +// | | | | | +// scheme authority path query fragment +// | _____________________|__ +// / \ / \ +// urn:example:animal:ferret:nose + +URI.assemble = function(bits) { + if ( bits === undefined ) { + bits = this.allBits; + } + var s = []; + if ( this.scheme && (bits & this.schemeBit) ) { + s.push(this.scheme, ':'); + } + if ( this.hostname && (bits & this.hostnameBit) ) { + s.push('//', this.hostname); + } + if ( this.port && (bits & this.portBit) ) { + s.push(':', this.port); + } + if ( this.path && (bits & this.pathBit) ) { + s.push(this.path); + } + if ( this.query && (bits & this.queryBit) ) { + s.push('?', this.query); + } + if ( this.fragment && (bits & this.fragmentBit) ) { + s.push('#', this.fragment); + } + return s.join(''); +}; + +/******************************************************************************/ + +URI.originFromURI = function(uri) { + var matches = reOriginFromURI.exec(uri); + return matches !== null ? matches[0].toLowerCase() : ''; +}; + +/******************************************************************************/ + +URI.schemeFromURI = function(uri) { + var matches = reSchemeFromURI.exec(uri); + if ( matches === null ) { + return ''; + } + return matches[0].slice(0, -1).toLowerCase(); +}; + +/******************************************************************************/ + +URI.isNetworkScheme = function(scheme) { + return this.reNetworkScheme.test(scheme); +}; + +URI.reNetworkScheme = /^(?:https?|wss?|ftps?)\b/; + +/******************************************************************************/ + +URI.isSecureScheme = function(scheme) { + return this.reSecureScheme.test(scheme); +}; + +URI.reSecureScheme = /^(?:https|wss|ftps)\b/; + +/******************************************************************************/ + +URI.authorityFromURI = function(uri) { + var matches = reAuthorityFromURI.exec(uri); + if ( !matches ) { + return ''; + } + return matches[1].slice(2).toLowerCase(); +}; + +/******************************************************************************/ + +// The most used function, so it better be fast. + +// https://github.com/gorhill/uBlock/issues/1559 +// See http://en.wikipedia.org/wiki/FQDN +// https://bugzilla.mozilla.org/show_bug.cgi?id=1360285 +// Revisit punycode dependency when above issue is fixed in Firefox. + +URI.hostnameFromURI = function(uri) { + var matches = reCommonHostnameFromURL.exec(uri); + if ( matches !== null ) { return matches[1]; } + matches = reAuthorityFromURI.exec(uri); + if ( matches === null ) { return ''; } + var authority = matches[1].slice(2); + // Assume very simple authority (most common case for µBlock) + if ( reHostFromNakedAuthority.test(authority) ) { + return authority.toLowerCase(); + } + matches = reHostFromAuthority.exec(authority); + if ( matches === null ) { + matches = reIPv6FromAuthority.exec(authority); + if ( matches === null ) { return ''; } + } + var hostname = matches[1]; + while ( hostname.endsWith('.') ) { + hostname = hostname.slice(0, -1); + } + if ( reMustNormalizeHostname.test(hostname) ) { + hostname = punycode.toASCII(hostname.toLowerCase()); + } + return hostname; +}; + +/******************************************************************************/ + +URI.domainFromHostname = function(hostname) { + // Try to skip looking up the PSL database + var entry = domainCache.get(hostname); + if ( entry !== undefined ) { + entry.tstamp = Date.now(); + return entry.domain; + } + // Meh.. will have to search it + if ( reIPAddressNaive.test(hostname) === false ) { + return domainCacheAdd(hostname, psl.getDomain(hostname)); + } + return domainCacheAdd(hostname, hostname); +}; + +URI.domain = function() { + return this.domainFromHostname(this.hostname); +}; + +// It is expected that there is higher-scoped `publicSuffixList` lingering +// somewhere. Cache it. See <https://github.com/gorhill/publicsuffixlist.js>. +var psl = publicSuffixList; + +/******************************************************************************/ + +URI.pathFromURI = function(uri) { + var matches = rePathFromURI.exec(uri); + return matches !== null ? matches[1] : ''; +}; + +/******************************************************************************/ + + // Trying to alleviate the worries of looking up too often the domain name from +// a hostname. With a cache, uBlock benefits given that it deals with a +// specific set of hostnames within a narrow time span -- in other words, I +// believe probability of cache hit are high in uBlock. + +var domainCache = new Map(); +var domainCacheCountLowWaterMark = 75; +var domainCacheCountHighWaterMark = 100; +var domainCacheEntryJunkyard = []; +var domainCacheEntryJunkyardMax = domainCacheCountHighWaterMark - domainCacheCountLowWaterMark; + +var DomainCacheEntry = function(domain) { + this.init(domain); +}; + +DomainCacheEntry.prototype.init = function(domain) { + this.domain = domain; + this.tstamp = Date.now(); + return this; +}; + +DomainCacheEntry.prototype.dispose = function() { + this.domain = ''; + if ( domainCacheEntryJunkyard.length < domainCacheEntryJunkyardMax ) { + domainCacheEntryJunkyard.push(this); + } +}; + +var domainCacheEntryFactory = function(domain) { + var entry = domainCacheEntryJunkyard.pop(); + if ( entry ) { + return entry.init(domain); + } + return new DomainCacheEntry(domain); +}; + +var domainCacheAdd = function(hostname, domain) { + var entry = domainCache.get(hostname); + if ( entry !== undefined ) { + entry.tstamp = Date.now(); + } else { + domainCache.set(hostname, domainCacheEntryFactory(domain)); + if ( domainCache.size === domainCacheCountHighWaterMark ) { + domainCachePrune(); + } + } + return domain; +}; + +var domainCacheEntrySort = function(a, b) { + return domainCache.get(b).tstamp - domainCache.get(a).tstamp; +}; + +var domainCachePrune = function() { + var hostnames = Array.from(domainCache.keys()) + .sort(domainCacheEntrySort) + .slice(domainCacheCountLowWaterMark); + var i = hostnames.length; + var hostname; + while ( i-- ) { + hostname = hostnames[i]; + domainCache.get(hostname).dispose(); + domainCache.delete(hostname); + } +}; + +var domainCacheReset = function() { + domainCache.clear(); +}; + +psl.onChanged.addListener(domainCacheReset); + +/******************************************************************************/ + +URI.domainFromURI = function(uri) { + if ( !uri ) { + return ''; + } + return this.domainFromHostname(this.hostnameFromURI(uri)); +}; + +/******************************************************************************/ + +// Normalize the way µMatrix expects it + +URI.normalizedURI = function() { + // Will be removed: + // - port + // - user id/password + // - fragment + return this.assemble(this.normalizeBits); +}; + +/******************************************************************************/ + +URI.rootURL = function() { + if ( !this.hostname ) { + return ''; + } + return this.assemble(this.schemeBit | this.hostnameBit); +}; + +/******************************************************************************/ + +URI.isValidHostname = function(hostname) { + var r; + try { + r = reValidHostname.test(hostname); + } + catch (e) { + return false; + } + return r; +}; + +/******************************************************************************/ + +// Return the parent domain. For IP address, there is no parent domain. + +URI.parentHostnameFromHostname = function(hostname) { + // `locahost` => `` + // `example.org` => `example.org` + // `www.example.org` => `example.org` + // `tomato.www.example.org` => `example.org` + var domain = this.domainFromHostname(hostname); + + // `locahost` === `` => bye + // `example.org` === `example.org` => bye + // `www.example.org` !== `example.org` => stay + // `tomato.www.example.org` !== `example.org` => stay + if ( domain === '' || domain === hostname ) { + return undefined; + } + + // Parent is hostname minus first label + return hostname.slice(hostname.indexOf('.') + 1); +}; + +/******************************************************************************/ + +// Return all possible parent hostnames which can be derived from `hostname`, +// ordered from direct parent up to domain inclusively. + +URI.parentHostnamesFromHostname = function(hostname) { + // TODO: I should create an object which is optimized to receive + // the list of hostnames by making it reusable (junkyard etc.) and which + // has its own element counter property in order to avoid memory + // alloc/dealloc. + var domain = this.domainFromHostname(hostname); + if ( domain === '' || domain === hostname ) { + return []; + } + var nodes = []; + var pos; + for (;;) { + pos = hostname.indexOf('.'); + if ( pos < 0 ) { + break; + } + hostname = hostname.slice(pos + 1); + nodes.push(hostname); + if ( hostname === domain ) { + break; + } + } + return nodes; +}; + +/******************************************************************************/ + +// Return all possible hostnames which can be derived from `hostname`, +// ordered from self up to domain inclusively. + +URI.allHostnamesFromHostname = function(hostname) { + var nodes = this.parentHostnamesFromHostname(hostname); + nodes.unshift(hostname); + return nodes; +}; + +/******************************************************************************/ + +URI.toString = function() { + return this.assemble(); +}; + +/******************************************************************************/ + +// Export + +return URI; + +/******************************************************************************/ + +})(); + +/******************************************************************************/ + diff --git a/js/user-rules.js b/js/user-rules.js new file mode 100644 index 0000000..c9c48be --- /dev/null +++ b/js/user-rules.js @@ -0,0 +1,341 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global uDom */ + +'use strict'; + +/******************************************************************************/ + +(function() { + +/******************************************************************************/ + +// Switches before, rules after + +var directiveSort = function(a, b) { + var aIsSwitch = a.indexOf(':') !== -1; + var bIsSwitch = b.indexOf(':') !== -1; + if ( aIsSwitch === bIsSwitch ) { + return a.localeCompare(b); + } + return aIsSwitch ? -1 : 1; +}; + +/******************************************************************************/ + +var processUserRules = function(response) { + var rules, rule, i; + var allRules = {}; + var permanentRules = {}; + var temporaryRules = {}; + var onLeft, onRight; + + rules = response.permanentRules.split(/\n+/); + i = rules.length; + while ( i-- ) { + rule = rules[i].trim(); + if ( rule.length !== 0 ) { + permanentRules[rule] = allRules[rule] = true; + } + } + rules = response.temporaryRules.split(/\n+/); + i = rules.length; + while ( i-- ) { + rule = rules[i].trim(); + if ( rule.length !== 0 ) { + temporaryRules[rule] = allRules[rule] = true; + } + } + + var permanentList = document.createDocumentFragment(), + temporaryList = document.createDocumentFragment(), + li; + + rules = Object.keys(allRules).sort(directiveSort); + for ( i = 0; i < rules.length; i++ ) { + rule = rules[i]; + onLeft = permanentRules.hasOwnProperty(rule); + onRight = temporaryRules.hasOwnProperty(rule); + if ( onLeft && onRight ) { + li = document.createElement('li'); + li.textContent = rule; + permanentList.appendChild(li); + li = document.createElement('li'); + li.textContent = rule; + temporaryList.appendChild(li); + } else if ( onLeft ) { + li = document.createElement('li'); + li.textContent = rule; + permanentList.appendChild(li); + li = document.createElement('li'); + li.textContent = rule; + li.className = 'notRight toRemove'; + temporaryList.appendChild(li); + } else if ( onRight ) { + li = document.createElement('li'); + li.textContent = '\xA0'; + permanentList.appendChild(li); + li = document.createElement('li'); + li.textContent = rule; + li.className = 'notLeft'; + temporaryList.appendChild(li); + } + } + + // TODO: build incrementally. + + uDom('#diff > .left > ul > li').remove(); + document.querySelector('#diff > .left > ul').appendChild(permanentList); + uDom('#diff > .right > ul > li').remove(); + document.querySelector('#diff > .right > ul').appendChild(temporaryList); + uDom('#diff').toggleClass('dirty', response.temporaryRules !== response.permanentRules); +}; + +/******************************************************************************/ + +// https://github.com/chrisaljoudi/uBlock/issues/757 +// Support RequestPolicy rule syntax + +var fromRequestPolicy = function(content) { + var matches = /\[origins-to-destinations\]([^\[]+)/.exec(content); + if ( matches === null || matches.length !== 2 ) { + return; + } + return matches[1].trim() + .replace(/\|/g, ' ') + .replace(/\n/g, ' * allow\n'); +}; + +/******************************************************************************/ + +// https://github.com/gorhill/uMatrix/issues/270 + +var fromNoScript = function(content) { + var noscript = null; + try { + noscript = JSON.parse(content); + } catch (e) { + } + if ( + noscript === null || + typeof noscript !== 'object' || + typeof noscript.prefs !== 'object' || + typeof noscript.prefs.clearClick === 'undefined' || + typeof noscript.whitelist !== 'string' || + typeof noscript.V !== 'string' + ) { + return; + } + var out = new Set(); + var reBad = /[a-z]+:\w*$/; + var reURL = /[a-z]+:\/\/([0-9a-z.-]+)/; + var directives = noscript.whitelist.split(/\s+/); + var i = directives.length; + var directive, matches; + while ( i-- ) { + directive = directives[i].trim(); + if ( directive === '' ) { + continue; + } + if ( reBad.test(directive) ) { + continue; + } + matches = reURL.exec(directive); + if ( matches !== null ) { + directive = matches[1]; + } + out.add('* ' + directive + ' * allow'); + out.add('* ' + directive + ' script allow'); + out.add('* ' + directive + ' frame allow'); + } + return Array.from(out).join('\n'); +}; + +/******************************************************************************/ + +var handleImportFilePicker = function() { + var fileReaderOnLoadHandler = function() { + if ( typeof this.result !== 'string' || this.result === '' ) { + return; + } + var result = fromRequestPolicy(this.result); + if ( result === undefined ) { + result = fromNoScript(this.result); + if ( result === undefined ) { + result = this.result; + } + } + if ( this.result === '' ) { return; } + var request = { + 'what': 'setUserRules', + 'temporaryRules': rulesFromHTML('#diff .right li') + '\n' + result + }; + vAPI.messaging.send('user-rules.js', request, processUserRules); + }; + var file = this.files[0]; + if ( file === undefined || file.name === '' ) { + return; + } + if ( file.type.indexOf('text') !== 0 && file.type !== 'application/json') { + return; + } + var fr = new FileReader(); + fr.onload = fileReaderOnLoadHandler; + fr.readAsText(file); +}; + +/******************************************************************************/ + +var startImportFilePicker = function() { + var input = document.getElementById('importFilePicker'); + // Reset to empty string, this will ensure an change event is properly + // triggered if the user pick a file, even if it is the same as the last + // one picked. + input.value = ''; + input.click(); +}; + +/******************************************************************************/ + +function exportUserRulesToFile() { + vAPI.download({ + 'url': 'data:text/plain,' + encodeURIComponent(rulesFromHTML('#diff .left li') + '\n'), + 'filename': uDom('[data-i18n="userRulesDefaultFileName"]').text() + }); +} + +/******************************************************************************/ + +var rulesFromHTML = function(selector) { + var rules = []; + var lis = uDom(selector); + var li; + for ( var i = 0; i < lis.length; i++ ) { + li = lis.at(i); + if ( li.hasClassName('toRemove') ) { + rules.push(''); + } else { + rules.push(li.text()); + } + } + return rules.join('\n'); +}; + +/******************************************************************************/ + +var revertHandler = function() { + var request = { + 'what': 'setUserRules', + 'temporaryRules': rulesFromHTML('#diff .left li') + }; + vAPI.messaging.send('user-rules.js', request, processUserRules); +}; + +/******************************************************************************/ + +var commitHandler = function() { + var request = { + 'what': 'setUserRules', + 'permanentRules': rulesFromHTML('#diff .right li') + }; + vAPI.messaging.send('user-rules.js', request, processUserRules); +}; + +/******************************************************************************/ + +var editStartHandler = function() { + uDom('#diff .right textarea').val(rulesFromHTML('#diff .right li')); + var parent = uDom(this).ancestors('#diff'); + parent.toggleClass('edit', true); +}; + +/******************************************************************************/ + +var editStopHandler = function() { + var parent = uDom(this).ancestors('#diff'); + parent.toggleClass('edit', false); + var request = { + 'what': 'setUserRules', + 'temporaryRules': uDom('#diff .right textarea').val() + }; + vAPI.messaging.send('user-rules.js', request, processUserRules); +}; + +/******************************************************************************/ + +var editCancelHandler = function() { + var parent = uDom(this).ancestors('#diff'); + parent.toggleClass('edit', false); +}; + +/******************************************************************************/ + +var temporaryRulesToggler = function() { + var li = uDom(this); + li.toggleClass('toRemove'); + var request = { + 'what': 'setUserRules', + 'temporaryRules': rulesFromHTML('#diff .right li') + }; + vAPI.messaging.send('user-rules.js', request, processUserRules); +}; + +/******************************************************************************/ + +self.cloud.onPush = function() { + return rulesFromHTML('#diff .left li'); +}; + +self.cloud.onPull = function(data, append) { + if ( typeof data !== 'string' ) { return; } + if ( append ) { + data = rulesFromHTML('#diff .right li') + '\n' + data; + } + var request = { + 'what': 'setUserRules', + 'temporaryRules': data + }; + vAPI.messaging.send('user-rules.js', request, processUserRules); +}; + +/******************************************************************************/ + +uDom.onLoad(function() { + // Handle user interaction + uDom('#importButton').on('click', startImportFilePicker); + uDom('#importFilePicker').on('change', handleImportFilePicker); + uDom('#exportButton').on('click', exportUserRulesToFile); + uDom('#revertButton').on('click', revertHandler); + uDom('#commitButton').on('click', commitHandler); + uDom('#editEnterButton').on('click', editStartHandler); + uDom('#editStopButton').on('click', editStopHandler); + uDom('#editCancelButton').on('click', editCancelHandler); + uDom('#diff > .right > ul').on('click', 'li', temporaryRulesToggler); + + vAPI.messaging.send('user-rules.js', { what: 'getUserRules' }, processUserRules); +}); + +/******************************************************************************/ + +})(); + diff --git a/js/usersettings.js b/js/usersettings.js new file mode 100644 index 0000000..b5c5b51 --- /dev/null +++ b/js/usersettings.js @@ -0,0 +1,59 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +'use strict'; + +/******************************************************************************/ + +µMatrix.changeUserSettings = function(name, value) { + if ( typeof name !== 'string' || name === '' ) { + return; + } + + // Do not allow an unknown user setting to be created + if ( this.userSettings[name] === undefined ) { + return; + } + + if ( value === undefined ) { + return this.userSettings[name]; + } + + // Pre-change + switch ( name ) { + + default: + break; + } + + // Change + this.userSettings[name] = value; + + // Post-change + switch ( name ) { + + default: + break; + } + + this.saveUserSettings(); +}; diff --git a/js/utils.js b/js/utils.js new file mode 100644 index 0000000..3b1d170 --- /dev/null +++ b/js/utils.js @@ -0,0 +1,106 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +'use strict'; + +/******************************************************************************/ + +µMatrix.gotoURL = function(details) { + vAPI.tabs.open(details); +}; + +/******************************************************************************/ + +µMatrix.gotoExtensionURL = function(details) { + if ( details.url.startsWith('logger-ui.html') ) { + if ( details.shiftKey ) { + this.changeUserSettings( + 'alwaysDetachLogger', + !this.userSettings.alwaysDetachLogger + ); + } + details.popup = this.userSettings.alwaysDetachLogger; + } + details.select = true; + vAPI.tabs.open(details); +}; + +/******************************************************************************/ + +µMatrix.LineIterator = function(text, offset) { + this.text = text; + this.textLen = this.text.length; + this.offset = offset || 0; +}; + +µMatrix.LineIterator.prototype = { + next: function() { + var lineEnd = this.text.indexOf('\n', this.offset); + if ( lineEnd === -1 ) { + lineEnd = this.text.indexOf('\r', this.offset); + if ( lineEnd === -1 ) { + lineEnd = this.textLen; + } + } + var line = this.text.slice(this.offset, lineEnd); + this.offset = lineEnd + 1; + return line; + }, + rewind: function() { + if ( this.offset <= 1 ) { + this.offset = 0; + return; + } + var lineEnd = this.text.lastIndexOf('\n', this.offset - 2); + if ( lineEnd !== -1 ) { + this.offset = lineEnd + 1; + } else { + lineEnd = this.text.lastIndexOf('\r', this.offset - 2); + this.offset = lineEnd !== -1 ? lineEnd + 1 : 0; + } + }, + eot: function() { + return this.offset >= this.textLen; + } +}; + +/******************************************************************************/ + +µMatrix.setToArray = typeof Array.from === 'function' ? + Array.from : + function(dict) { + var out = [], + entries = dict.values(), + entry; + for (;;) { + entry = entries.next(); + if ( entry.done ) { break; } + out.push(entry.value); + } + return out; + }; + +µMatrix.setFromArray = function(arr) { + return new Set(arr); +}; + +/******************************************************************************/ diff --git a/js/vapi-background.js b/js/vapi-background.js new file mode 100644 index 0000000..b4f1eac --- /dev/null +++ b/js/vapi-background.js @@ -0,0 +1,3466 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 The uMatrix/uBlock Origin authors + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* jshint bitwise: false, esnext: true */ +/* global self, Components, punycode */ + +// For background page + +'use strict'; + +/******************************************************************************/ + +(function() { + +/******************************************************************************/ + +// Useful links +// +// https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface +// https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/Services.jsm + +/******************************************************************************/ + +const {classes: Cc, interfaces: Ci, utils: Cu} = Components; +const {Services} = Cu.import('resource://gre/modules/Services.jsm', null); + +/******************************************************************************/ + +var vAPI = self.vAPI = self.vAPI || {}; +vAPI.firefox = true; +vAPI.modernFirefox = Services.appinfo.ID === '{ec8030f7-c20a-464f-9b0e-13a3a9e97384}' && + Services.vc.compare(Services.appinfo.platformVersion, '44') > 0; + +/******************************************************************************/ + +var deferUntil = function(testFn, mainFn, details) { + if ( typeof details !== 'object' ) { + details = {}; + } + + var now = 0; + var next = details.next || 200; + var until = details.until || 2000; + + var check = function() { + if ( testFn() === true || now >= until ) { + mainFn(); + return; + } + now += next; + vAPI.setTimeout(check, next); + }; + + if ( 'sync' in details && details.sync === true ) { + check(); + } else { + vAPI.setTimeout(check, 1); + } +}; + +/******************************************************************************/ + +vAPI.app = { + name: 'uMatrix', + version: location.hash.slice(1) +}; + +/******************************************************************************/ + +vAPI.app.start = function() { +}; + +/******************************************************************************/ + +vAPI.app.stop = function() { +}; + +/******************************************************************************/ + +vAPI.app.restart = function() { + // Listening in bootstrap.js + Cc['@mozilla.org/childprocessmessagemanager;1'] + .getService(Ci.nsIMessageSender) + .sendAsyncMessage(location.host + '-restart'); +}; + +/******************************************************************************/ + +// List of things that needs to be destroyed when disabling the extension +// Only functions should be added to it + +var cleanupTasks = []; + +// This must be updated manually, every time a new task is added/removed + +// Fixed by github.com/AlexVallat: +// https://github.com/AlexVallat/uBlock/commit/7b781248f00cbe3d61b1cc367c440db80fa06049 +// 7 instances of cleanupTasks.push, but one is unique to fennec, and one to desktop. +var expectedNumberOfCleanups = 7; + +window.addEventListener('unload', function() { + if ( typeof vAPI.app.onShutdown === 'function' ) { + vAPI.app.onShutdown(); + } + + // IMPORTANT: cleanup tasks must be executed using LIFO order. + var i = cleanupTasks.length; + while ( i-- ) { + cleanupTasks[i](); + } + + if ( cleanupTasks.length < expectedNumberOfCleanups ) { + console.error( + 'uMatrix> Cleanup tasks performed: %s (out of %s)', + cleanupTasks.length, + expectedNumberOfCleanups + ); + } + + // frameModule needs to be cleared too + var frameModuleURL = vAPI.getURL('frameModule.js'); + var frameModule = {}; + Cu.import(frameModuleURL, frameModule); + frameModule.contentObserver.unregister(); + Cu.unload(frameModuleURL); +}); + +/******************************************************************************/ + +// For now, only booleans. + +vAPI.browserSettings = { + originalValues: {}, + + rememberOriginalValue: function(path, setting) { + var key = path + '.' + setting; + if ( this.originalValues.hasOwnProperty(key) ) { + return; + } + var hasUserValue; + var branch = Services.prefs.getBranch(path + '.'); + try { + hasUserValue = branch.prefHasUserValue(setting); + } catch (ex) { + } + if ( hasUserValue !== undefined ) { + this.originalValues[key] = hasUserValue ? this.getValue(path, setting) : undefined; + } + }, + + clear: function(path, setting) { + var key = path + '.' + setting; + + // Value was not overriden -- nothing to restore + if ( this.originalValues.hasOwnProperty(key) === false ) { + return; + } + + var value = this.originalValues[key]; + // https://github.com/gorhill/uBlock/issues/292#issuecomment-109621979 + // Forget the value immediately, it may change outside of + // uBlock control. + delete this.originalValues[key]; + + // Original value was a default one + if ( value === undefined ) { + try { + Services.prefs.getBranch(path + '.').clearUserPref(setting); + } catch (ex) { + } + return; + } + + // Reset to original value + this.setValue(path, setting, value); + }, + + getValue: function(path, setting) { + var branch = Services.prefs.getBranch(path + '.'); + var getMethod; + + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPrefBranch#getPrefType%28%29 + switch ( branch.getPrefType(setting) ) { + case 64: // PREF_INT + getMethod = 'getIntPref'; + break; + case 128: // PREF_BOOL + getMethod = 'getBoolPref'; + break; + default: // not supported + return; + } + + try { + return branch[getMethod](setting); + } catch (ex) { + } + }, + + setValue: function(path, setting, value) { + var setMethod; + switch ( typeof value ) { + case 'number': + setMethod = 'setIntPref'; + break; + case 'boolean': + setMethod = 'setBoolPref'; + break; + default: // not supported + return; + } + + try { + Services.prefs.getBranch(path + '.')[setMethod](setting, value); + } catch (ex) { + } + }, + + setSetting: function(setting, value) { + var prefName, prefVal; + switch ( setting ) { + case 'prefetching': + this.rememberOriginalValue('network', 'prefetch-next'); + // http://betanews.com/2015/08/15/firefox-stealthily-loads-webpages-when-you-hover-over-links-heres-how-to-stop-it/ + // https://bugzilla.mozilla.org/show_bug.cgi?id=814169 + // Sigh. + this.rememberOriginalValue('network.http', 'speculative-parallel-limit'); + // https://github.com/gorhill/uBlock/issues/292 + // "true" means "do not disable", i.e. leave entry alone + if ( value ) { + this.clear('network', 'prefetch-next'); + this.clear('network.http', 'speculative-parallel-limit'); + } else { + this.setValue('network', 'prefetch-next', false); + this.setValue('network.http', 'speculative-parallel-limit', 0); + } + break; + + case 'hyperlinkAuditing': + this.rememberOriginalValue('browser', 'send_pings'); + this.rememberOriginalValue('beacon', 'enabled'); + // https://github.com/gorhill/uBlock/issues/292 + // "true" means "do not disable", i.e. leave entry alone + if ( value ) { + this.clear('browser', 'send_pings'); + this.clear('beacon', 'enabled'); + } else { + this.setValue('browser', 'send_pings', false); + this.setValue('beacon', 'enabled', false); + } + break; + + // https://github.com/gorhill/uBlock/issues/894 + // Do not disable completely WebRTC if it can be avoided. FF42+ + // has a `media.peerconnection.ice.default_address_only` pref which + // purpose is to prevent local IP address leakage. + case 'webrtcIPAddress': + if ( this.getValue('media.peerconnection', 'ice.default_address_only') !== undefined ) { + prefName = 'ice.default_address_only'; + prefVal = true; + } else { + prefName = 'enabled'; + prefVal = false; + } + + this.rememberOriginalValue('media.peerconnection', prefName); + if ( value ) { + this.clear('media.peerconnection', prefName); + } else { + this.setValue('media.peerconnection', prefName, prefVal); + } + break; + + default: + break; + } + }, + + set: function(details) { + for ( var setting in details ) { + if ( details.hasOwnProperty(setting) === false ) { + continue; + } + this.setSetting(setting, !!details[setting]); + } + }, + + restoreAll: function() { + var pos; + for ( var key in this.originalValues ) { + if ( this.originalValues.hasOwnProperty(key) === false ) { + continue; + } + pos = key.lastIndexOf('.'); + this.clear(key.slice(0, pos), key.slice(pos + 1)); + } + } +}; + +cleanupTasks.push(vAPI.browserSettings.restoreAll.bind(vAPI.browserSettings)); + +/******************************************************************************/ + +// API matches that of chrome.storage.local: +// https://developer.chrome.com/extensions/storage + +vAPI.storage = (function() { + var db = null; + var vacuumTimer = null; + + var close = function() { + if ( vacuumTimer !== null ) { + clearTimeout(vacuumTimer); + vacuumTimer = null; + } + if ( db === null ) { + return; + } + db.asyncClose(); + db = null; + }; + + var open = function() { + if ( db !== null ) { + return db; + } + + // Create path + var path = Services.dirsvc.get('ProfD', Ci.nsIFile); + path.append('extension-data'); + if ( !path.exists() ) { + path.create(Ci.nsIFile.DIRECTORY_TYPE, parseInt('0774', 8)); + } + if ( !path.isDirectory() ) { + throw Error('Should be a directory...'); + } + path.append(location.host + '.sqlite'); + + // Open database + try { + db = Services.storage.openDatabase(path); + if ( db.connectionReady === false ) { + db.asyncClose(); + db = null; + } + } catch (ex) { + } + + if ( db === null ) { + return null; + } + + // Database was opened, register cleanup task + cleanupTasks.push(close); + + // Setup database + db.createAsyncStatement('CREATE TABLE IF NOT EXISTS "settings" ("name" TEXT PRIMARY KEY NOT NULL, "value" TEXT);') + .executeAsync(); + + if ( vacuum !== null ) { + vacuumTimer = vAPI.setTimeout(vacuum, 60000); + } + + return db; + }; + + // https://developer.mozilla.org/en-US/docs/Storage/Performance#Vacuuming_and_zero-fill + // Vacuum only once, and only while idle + var vacuum = function() { + vacuumTimer = null; + if ( db === null ) { + return; + } + var idleSvc = Cc['@mozilla.org/widget/idleservice;1'] + .getService(Ci.nsIIdleService); + if ( idleSvc.idleTime < 60000 ) { + vacuumTimer = vAPI.setTimeout(vacuum, 60000); + return; + } + db.createAsyncStatement('VACUUM').executeAsync(); + vacuum = null; + }; + + // Execute a query + var runStatement = function(stmt, callback) { + var result = {}; + + stmt.executeAsync({ + handleResult: function(rows) { + if ( !rows || typeof callback !== 'function' ) { + return; + } + + var row; + + while ( (row = rows.getNextRow()) ) { + // we assume that there will be two columns, since we're + // using it only for preferences + result[row.getResultByIndex(0)] = row.getResultByIndex(1); + } + }, + handleCompletion: function(reason) { + if ( typeof callback === 'function' && reason === 0 ) { + callback(result); + } + }, + handleError: function(error) { + console.error('SQLite error ', error.result, error.message); + // Caller expects an answer regardless of failure. + if ( typeof callback === 'function' ) { + callback(null); + } + } + }); + }; + + var bindNames = function(stmt, names) { + if ( Array.isArray(names) === false || names.length === 0 ) { + return; + } + var params = stmt.newBindingParamsArray(); + var i = names.length, bp; + while ( i-- ) { + bp = params.newBindingParams(); + bp.bindByName('name', names[i]); + params.addParams(bp); + } + stmt.bindParameters(params); + }; + + var clear = function(callback) { + if ( open() === null ) { + if ( typeof callback === 'function' ) { + callback(); + } + return; + } + runStatement(db.createAsyncStatement('DELETE FROM "settings";'), callback); + }; + + var getBytesInUse = function(keys, callback) { + if ( typeof callback !== 'function' ) { + return; + } + + if ( open() === null ) { + callback(0); + return; + } + + var stmt; + if ( Array.isArray(keys) ) { + stmt = db.createAsyncStatement('SELECT "size" AS "size", SUM(LENGTH("value")) FROM "settings" WHERE "name" = :name'); + bindNames(keys); + } else { + stmt = db.createAsyncStatement('SELECT "size" AS "size", SUM(LENGTH("value")) FROM "settings"'); + } + + runStatement(stmt, function(result) { + callback(result.size); + }); + }; + + var read = function(details, callback) { + if ( typeof callback !== 'function' ) { + return; + } + + var prepareResult = function(result) { + var key; + for ( key in result ) { + if ( result.hasOwnProperty(key) === false ) { + continue; + } + result[key] = JSON.parse(result[key]); + } + if ( typeof details === 'object' && details !== null ) { + for ( key in details ) { + if ( result.hasOwnProperty(key) === false ) { + result[key] = details[key]; + } + } + } + callback(result); + }; + + if ( open() === null ) { + prepareResult({}); + return; + } + + var names = []; + if ( details !== null ) { + if ( Array.isArray(details) ) { + names = details; + } else if ( typeof details === 'object' ) { + names = Object.keys(details); + } else { + names = [details.toString()]; + } + } + + var stmt; + if ( names.length === 0 ) { + stmt = db.createAsyncStatement('SELECT * FROM "settings"'); + } else { + stmt = db.createAsyncStatement('SELECT * FROM "settings" WHERE "name" = :name'); + bindNames(stmt, names); + } + + runStatement(stmt, prepareResult); + }; + + var remove = function(keys, callback) { + if ( open() === null ) { + if ( typeof callback === 'function' ) { + callback(); + } + return; + } + var stmt = db.createAsyncStatement('DELETE FROM "settings" WHERE "name" = :name'); + bindNames(stmt, typeof keys === 'string' ? [keys] : keys); + runStatement(stmt, callback); + }; + + var write = function(details, callback) { + if ( open() === null ) { + if ( typeof callback === 'function' ) { + callback(); + } + return; + } + + var stmt = db.createAsyncStatement('INSERT OR REPLACE INTO "settings" ("name", "value") VALUES(:name, :value)'); + var params = stmt.newBindingParamsArray(), bp; + for ( var key in details ) { + if ( details.hasOwnProperty(key) === false ) { + continue; + } + bp = params.newBindingParams(); + bp.bindByName('name', key); + bp.bindByName('value', JSON.stringify(details[key])); + params.addParams(bp); + } + if ( params.length === 0 ) { + return; + } + + stmt.bindParameters(params); + runStatement(stmt, callback); + }; + + // Export API + var api = { + QUOTA_BYTES: 100 * 1024 * 1024, + clear: clear, + get: read, + getBytesInUse: getBytesInUse, + remove: remove, + set: write + }; + return api; +})(); + +vAPI.cacheStorage = vAPI.storage; + +/******************************************************************************/ + +// This must be executed/setup early. + +var winWatcher = (function() { + var windowToIdMap = new Map(); + var windowIdGenerator = 1; + var api = { + onOpenWindow: null, + onCloseWindow: null + }; + + // https://github.com/gorhill/uMatrix/issues/586 + // This is necessary hack because on SeaMonkey 2.40, for unknown reasons + // private windows do not have the attribute `windowtype` set to + // `navigator:browser`. As a fallback, the code here will also test whether + // the id attribute is `main-window`. + api.toBrowserWindow = function(win) { + var docElement = win && win.document && win.document.documentElement; + if ( !docElement ) { + return null; + } + if ( vAPI.thunderbird ) { + return docElement.getAttribute('windowtype') === 'mail:3pane' ? win : null; + } + return docElement.getAttribute('windowtype') === 'navigator:browser' || + docElement.getAttribute('id') === 'main-window' ? + win : null; + }; + + api.getWindows = function() { + return windowToIdMap.keys(); + }; + + api.idFromWindow = function(win) { + return windowToIdMap.get(win) || 0; + }; + + api.getCurrentWindow = function() { + return this.toBrowserWindow(Services.wm.getMostRecentWindow(null)); + }; + + var addWindow = function(win) { + if ( !win || windowToIdMap.has(win) ) { + return; + } + windowToIdMap.set(win, windowIdGenerator++); + if ( typeof api.onOpenWindow === 'function' ) { + api.onOpenWindow(win); + } + }; + + var removeWindow = function(win) { + if ( !win || windowToIdMap.delete(win) !== true ) { + return; + } + if ( typeof api.onCloseWindow === 'function' ) { + api.onCloseWindow(win); + } + }; + + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIWindowMediator + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIWindowWatcher + // https://github.com/gorhill/uMatrix/issues/357 + // Use nsIWindowMediator for being notified of opened/closed windows. + var listeners = { + onOpenWindow: function(aWindow) { + var win; + try { + win = aWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + } catch (ex) { + } + addWindow(win); + }, + + onCloseWindow: function(aWindow) { + var win; + try { + win = aWindow.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + } catch (ex) { + } + removeWindow(win); + }, + + observe: function(aSubject, topic) { + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIWindowWatcher#registerNotification%28%29 + // "aSubject - the window being opened or closed, sent as an + // "nsISupports which can be ... QueryInterfaced to an + // "nsIDOMWindow." + var win; + try { + win = aSubject.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + } catch (ex) { + } + if ( !win ) { return; } + if ( topic === 'domwindowopened' ) { + addWindow(win); + return; + } + if ( topic === 'domwindowclosed' ) { + removeWindow(win); + return; + } + } + }; + + (function() { + var winumerator, win; + + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIWindowMediator#getEnumerator%28%29 + winumerator = Services.wm.getEnumerator(null); + while ( winumerator.hasMoreElements() ) { + win = winumerator.getNext(); + if ( !win.closed ) { + windowToIdMap.set(win, windowIdGenerator++); + } + } + + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIWindowWatcher#getWindowEnumerator%28%29 + winumerator = Services.ww.getWindowEnumerator(); + while ( winumerator.hasMoreElements() ) { + win = winumerator.getNext() + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + if ( !win.closed ) { + windowToIdMap.set(win, windowIdGenerator++); + } + } + + Services.wm.addListener(listeners); + Services.ww.registerNotification(listeners); + })(); + + cleanupTasks.push(function() { + Services.wm.removeListener(listeners); + Services.ww.unregisterNotification(listeners); + windowToIdMap.clear(); + }); + + return api; +})(); + +/******************************************************************************/ + +var getTabBrowser = function(win) { + return win && win.gBrowser || null; +}; + +/******************************************************************************/ + +var getOwnerWindow = function(target) { + if ( target.ownerDocument ) { + return target.ownerDocument.defaultView; + } + return null; +}; + +/******************************************************************************/ + +vAPI.isBehindTheSceneTabId = function(tabId) { + return tabId.toString() === '-1'; +}; + +vAPI.noTabId = '-1'; + +/******************************************************************************/ + +vAPI.tabs = {}; + +/******************************************************************************/ + +vAPI.tabs.registerListeners = function() { + tabWatcher.start(); +}; + +/******************************************************************************/ + +// Firefox: +// https://developer.mozilla.org/en-US/Add-ons/Code_snippets/Tabbed_browser +// +// browser --> ownerDocument --> defaultView --> gBrowser --> browsers --+ +// ^ | +// | | +// +------------------------------------------------------------------- +// +// browser (browser) +// contentTitle +// currentURI +// ownerDocument (XULDocument) +// defaultView (ChromeWindow) +// gBrowser (tabbrowser OR browser) +// browsers (browser) +// selectedBrowser +// selectedTab +// tabs (tab.tabbrowser-tab) +// +// Fennec: (what I figured so far) +// +// tab --> browser windows --> window --> BrowserApp --> tabs --+ +// ^ window | +// | | +// +---------------------------------------------------------------+ +// +// tab +// browser +// [manual search to go back to tab from list of windows] + +vAPI.tabs.get = function(tabId, callback) { + var browser; + + if ( tabId === null ) { + browser = tabWatcher.currentBrowser(); + tabId = tabWatcher.tabIdFromTarget(browser); + } else { + browser = tabWatcher.browserFromTabId(tabId); + } + + // For internal use + if ( typeof callback !== 'function' ) { + return browser; + } + + if ( !browser || !browser.currentURI ) { + callback(); + return; + } + + var win = getOwnerWindow(browser); + var tabBrowser = getTabBrowser(win); + + // https://github.com/gorhill/uMatrix/issues/540 + // The `index` property is nowhere used by uMatrix at this point, so we + // will refrain from returning this information for the time being. + + callback({ + id: tabId, + index: undefined, + windowId: winWatcher.idFromWindow(win), + active: tabBrowser !== null && browser === tabBrowser.selectedBrowser, + url: browser.currentURI.asciiSpec, + title: browser.contentTitle + }); +}; + +/******************************************************************************/ + +vAPI.tabs.getAllSync = function(window) { + var win, tab; + var tabs = []; + + for ( win of winWatcher.getWindows() ) { + if ( window && window !== win ) { + continue; + } + + var tabBrowser = getTabBrowser(win); + if ( tabBrowser === null ) { + continue; + } + + // This can happens if a tab-less window is currently opened. + // Example of a tab-less window: one opened from clicking + // "View Page Source". + if ( !tabBrowser.tabs ) { + continue; + } + + for ( tab of tabBrowser.tabs ) { + tabs.push(tab); + } + } + + return tabs; +}; + +/******************************************************************************/ + +vAPI.tabs.getAll = function(callback) { + var tabs = [], tab; + + for ( var browser of tabWatcher.browsers() ) { + tab = tabWatcher.tabFromBrowser(browser); + if ( tab === null ) { + continue; + } + if ( tab.hasAttribute('pending') ) { + continue; + } + tabs.push({ + id: tabWatcher.tabIdFromTarget(browser), + url: browser.currentURI.asciiSpec + }); + } + + callback(tabs); +}; + +/******************************************************************************/ + +// properties of the details object: +// url: 'URL', // the address that will be opened +// tabId: 1, // the tab is used if set, instead of creating a new one +// index: -1, // undefined: end of the list, -1: following tab, or after index +// active: false, // opens the tab in background - true and undefined: foreground +// select: true // if a tab is already opened with that url, then select it instead of opening a new one + +vAPI.tabs.open = function(details) { + if ( !details.url ) { + return null; + } + // extension pages + if ( /^[\w-]{2,}:/.test(details.url) === false ) { + details.url = vAPI.getURL(details.url); + } + + var tab; + + if ( details.select ) { + var URI = Services.io.newURI(details.url, null, null); + + for ( tab of this.getAllSync() ) { + var browser = tabWatcher.browserFromTarget(tab); + // https://github.com/gorhill/uBlock/issues/2558 + if ( browser === null ) { continue; } + + // Or simply .equals if we care about the fragment + if ( URI.equalsExceptRef(browser.currentURI) === false ) { + continue; + } + + this.select(tab); + + // Update URL if fragment is different + if ( URI.equals(browser.currentURI) === false ) { + browser.loadURI(URI.asciiSpec); + } + return; + } + } + + if ( details.active === undefined ) { + details.active = true; + } + + if ( details.tabId ) { + tab = tabWatcher.browserFromTabId(details.tabId); + if ( tab ) { + tabWatcher.browserFromTarget(tab).loadURI(details.url); + return; + } + } + + // Open in a standalone window + if ( details.popup === true ) { + Services.ww.openWindow( + self, + details.url, + null, + 'location=1,menubar=1,personalbar=1,resizable=1,toolbar=1', + null + ); + return; + } + + var win = winWatcher.getCurrentWindow(); + var tabBrowser = getTabBrowser(win); + if ( tabBrowser === null ) { + return; + } + + if ( details.index === -1 ) { + details.index = tabBrowser.browsers.indexOf(tabBrowser.selectedBrowser) + 1; + } + + tab = tabBrowser.loadOneTab(details.url, { inBackground: !details.active }); + + if ( details.index !== undefined ) { + tabBrowser.moveTabTo(tab, details.index); + } +}; + +/******************************************************************************/ + +// Replace the URL of a tab. Noop if the tab does not exist. + +vAPI.tabs.replace = function(tabId, url) { + var targetURL = url; + + // extension pages + if ( /^[\w-]{2,}:/.test(targetURL) !== true ) { + targetURL = vAPI.getURL(targetURL); + } + + var browser = tabWatcher.browserFromTabId(tabId); + if ( browser ) { + browser.loadURI(targetURL); + } +}; + +/******************************************************************************/ + +vAPI.tabs._remove = function(tab, tabBrowser) { + if ( tabBrowser ) { + tabBrowser.removeTab(tab); + } +}; + +/******************************************************************************/ + +vAPI.tabs.remove = function(tabId) { + var browser = tabWatcher.browserFromTabId(tabId); + if ( !browser ) { + return; + } + var tab = tabWatcher.tabFromBrowser(browser); + if ( !tab ) { + return; + } + this._remove(tab, getTabBrowser(getOwnerWindow(browser))); +}; + +/******************************************************************************/ + +vAPI.tabs.reload = function(tabId) { + var browser = tabWatcher.browserFromTabId(tabId); + if ( !browser ) { + return; + } + + browser.webNavigation.reload(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE); +}; + +/******************************************************************************/ + +vAPI.tabs.select = function(tab) { + if ( typeof tab !== 'object' ) { + tab = tabWatcher.tabFromBrowser(tabWatcher.browserFromTabId(tab)); + } + if ( !tab ) { + return; + } + + // https://github.com/gorhill/uBlock/issues/470 + var win = getOwnerWindow(tab); + win.focus(); + + var tabBrowser = getTabBrowser(win); + if ( tabBrowser ) { + tabBrowser.selectedTab = tab; + } +}; + +/******************************************************************************/ + +vAPI.tabs.injectScript = function(tabId, details, callback) { + var browser = tabWatcher.browserFromTabId(tabId); + if ( !browser ) { + return; + } + + if ( typeof details.file !== 'string' ) { + return; + } + + details.file = vAPI.getURL(details.file); + browser.messageManager.sendAsyncMessage( + location.host + ':broadcast', + JSON.stringify({ + broadcast: true, + channelName: 'vAPI', + msg: { + cmd: 'injectScript', + details: details + } + }) + ); + + if ( typeof callback === 'function' ) { + vAPI.setTimeout(callback, 13); + } +}; + +/******************************************************************************/ + +var tabWatcher = (function() { + // TODO: find out whether we need a janitor to take care of stale entries. + + // https://github.com/gorhill/uMatrix/issues/540 + // Use only weak references to hold onto browser references. + var browserToTabIdMap = new WeakMap(); + var tabIdToBrowserMap = new Map(); + var tabIdGenerator = 1; + + var indexFromBrowser = function(browser) { + if ( !browser ) { + return -1; + } + var win = getOwnerWindow(browser); + if ( !win ) { + return -1; + } + var tabBrowser = getTabBrowser(win); + if ( tabBrowser === null ) { + return -1; + } + // This can happen, for example, the `view-source:` window, there is + // no tabbrowser object, the browser object sits directly in the + // window. + if ( tabBrowser === browser ) { + return 0; + } + return tabBrowser.browsers.indexOf(browser); + }; + + var indexFromTarget = function(target) { + return indexFromBrowser(browserFromTarget(target)); + }; + + var tabFromBrowser = function(browser) { + var i = indexFromBrowser(browser); + if ( i === -1 ) { + return null; + } + var win = getOwnerWindow(browser); + if ( !win ) { + return null; + } + var tabBrowser = getTabBrowser(win); + if ( tabBrowser === null ) { + return null; + } + if ( !tabBrowser.tabs || i >= tabBrowser.tabs.length ) { + return null; + } + return tabBrowser.tabs[i]; + }; + + var browserFromTarget = function(target) { + if ( !target ) { + return null; + } + if ( target.linkedPanel ) { // target is a tab + target = target.linkedBrowser; + } + if ( target.localName !== 'browser' ) { + return null; + } + return target; + }; + + var tabIdFromTarget = function(target) { + var browser = browserFromTarget(target); + if ( browser === null ) { + return vAPI.noTabId; + } + var tabId = browserToTabIdMap.get(browser); + if ( tabId === undefined ) { + tabId = '' + tabIdGenerator++; + browserToTabIdMap.set(browser, tabId); + tabIdToBrowserMap.set(tabId, Cu.getWeakReference(browser)); + } + return tabId; + }; + + var browserFromTabId = function(tabId) { + var weakref = tabIdToBrowserMap.get(tabId); + var browser = weakref && weakref.get(); + return browser || null; + }; + + var currentBrowser = function() { + var win = winWatcher.getCurrentWindow(); + // https://github.com/gorhill/uBlock/issues/399 + // getTabBrowser() can return null at browser launch time. + var tabBrowser = getTabBrowser(win); + if ( tabBrowser === null ) { + return null; + } + return browserFromTarget(tabBrowser.selectedTab); + }; + + var removeBrowserEntry = function(tabId, browser) { + if ( tabId && tabId !== vAPI.noTabId ) { + vAPI.tabs.onClosed(tabId); + delete vAPI.toolbarButton.tabs[tabId]; + tabIdToBrowserMap.delete(tabId); + } + if ( browser ) { + browserToTabIdMap.delete(browser); + } + }; + + var removeTarget = function(target) { + onClose({ target: target }); + }; + + var getAllBrowsers = function() { + var browsers = [], browser; + for ( var [tabId, weakref] of tabIdToBrowserMap ) { + browser = weakref.get(); + // TODO: + // Maybe call removeBrowserEntry() if the browser no longer exists? + if ( browser ) { + browsers.push(browser); + } + } + return browsers; + }; + + // https://developer.mozilla.org/en-US/docs/Web/Events/TabOpen + //var onOpen = function({target}) { + // var tabId = tabIdFromTarget(target); + // var browser = browserFromTabId(tabId); + // vAPI.tabs.onNavigation({ + // frameId: 0, + // tabId: tabId, + // url: browser.currentURI.asciiSpec, + // }); + //}; + + // https://developer.mozilla.org/en-US/docs/Web/Events/TabShow + var onShow = function({target}) { + tabIdFromTarget(target); + }; + + // https://developer.mozilla.org/en-US/docs/Web/Events/TabClose + var onClose = function({target}) { + // target is tab in Firefox, browser in Fennec + var browser = browserFromTarget(target); + var tabId = browserToTabIdMap.get(browser); + removeBrowserEntry(tabId, browser); + }; + + // https://developer.mozilla.org/en-US/docs/Web/Events/TabSelect + // This is an entry point: when creating a new tab, it is not always + // reported through onLocationChanged... Sigh. It is "reported" here + // however. + var onSelect = function({target}) { + var browser = browserFromTarget(target); + var tabId = browserToTabIdMap.get(browser); + if ( tabId === undefined ) { + tabId = tabIdFromTarget(target); + vAPI.tabs.onNavigation({ + frameId: 0, + tabId: tabId, + url: browser.currentURI.asciiSpec + }); + } + vAPI.setIcon(tabId, getOwnerWindow(target)); + }; + + var locationChangedMessageName = location.host + ':locationChanged'; + + var onLocationChanged = function(e) { + var vapi = vAPI; + var details = e.data; + + // Ignore notifications related to our popup + if ( details.url.lastIndexOf(vapi.getURL('popup.html'), 0) === 0 ) { + return; + } + + var browser = e.target; + var tabId = tabIdFromTarget(browser); + if ( tabId === vapi.noTabId ) { + return; + } + + // LOCATION_CHANGE_SAME_DOCUMENT = "did not load a new document" + if ( details.flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT ) { + vapi.tabs.onUpdated(tabId, {url: details.url}, { + frameId: 0, + tabId: tabId, + url: browser.currentURI.asciiSpec + }); + return; + } + + // https://github.com/chrisaljoudi/uBlock/issues/105 + // Allow any kind of pages + vapi.tabs.onNavigation({ + frameId: 0, + tabId: tabId, + url: details.url + }); + }; + + var attachToTabBrowser = function(window) { + if ( typeof vAPI.toolbarButton.attachToNewWindow === 'function' ) { + vAPI.toolbarButton.attachToNewWindow(window); + } + + var tabBrowser = getTabBrowser(window); + if ( tabBrowser === null ) { + return; + } + + var tabContainer; + if ( tabBrowser.deck ) { // Fennec + tabContainer = tabBrowser.deck; + } else if ( tabBrowser.tabContainer ) { // Firefox + tabContainer = tabBrowser.tabContainer; + vAPI.contextMenu.register(document); + } + + // https://github.com/gorhill/uBlock/issues/697 + // Ignore `TabShow` events: unfortunately the `pending` attribute is + // not set when a tab is opened as a result of session restore -- it is + // set *after* the event is fired in such case. + if ( tabContainer ) { + tabContainer.addEventListener('TabShow', onShow); + tabContainer.addEventListener('TabClose', onClose); + // when new window is opened TabSelect doesn't run on the selected tab? + tabContainer.addEventListener('TabSelect', onSelect); + } + }; + + // https://github.com/gorhill/uBlock/issues/906 + // Ensure the environment is ready before trying to attaching. + var canAttachToTabBrowser = function(window) { + var document = window && window.document; + if ( !document || document.readyState !== 'complete' ) { + return false; + } + + // On some platforms, the tab browser isn't immediately available, + // try waiting a bit if this happens. + // https://github.com/gorhill/uBlock/issues/763 + // Not getting a tab browser should not prevent from attaching ourself + // to the window. + var tabBrowser = getTabBrowser(window); + if ( tabBrowser === null ) { + return false; + } + + return winWatcher.toBrowserWindow(window) !== null; + }; + + var onWindowLoad = function(win) { + deferUntil( + canAttachToTabBrowser.bind(null, win), + attachToTabBrowser.bind(null, win) + ); + }; + + var onWindowUnload = function(win) { + vAPI.contextMenu.unregister(win.document); + + var tabBrowser = getTabBrowser(win); + if ( tabBrowser === null ) { + return; + } + + var tabContainer = tabBrowser.tabContainer; + if ( tabContainer ) { + tabContainer.removeEventListener('TabShow', onShow); + tabContainer.removeEventListener('TabClose', onClose); + tabContainer.removeEventListener('TabSelect', onSelect); + } + + // https://github.com/gorhill/uBlock/issues/574 + // To keep in mind: not all windows are tab containers, + // sometimes the window IS the tab. + var tabs; + if ( tabBrowser.tabs ) { + tabs = tabBrowser.tabs; + } else if ( tabBrowser.localName === 'browser' ) { + tabs = [tabBrowser]; + } else { + tabs = []; + } + + var browser, URI, tabId; + var tabindex = tabs.length, tab; + while ( tabindex-- ) { + tab = tabs[tabindex]; + browser = browserFromTarget(tab); + if ( browser === null ) { + continue; + } + URI = browser.currentURI; + // Close extension tabs + if ( URI.schemeIs('chrome') && URI.host === location.host ) { + vAPI.tabs._remove(tab, getTabBrowser(win)); + } + tabId = browserToTabIdMap.get(browser); + if ( tabId !== undefined ) { + removeBrowserEntry(tabId, browser); + tabIdToBrowserMap.delete(tabId); + } + browserToTabIdMap.delete(browser); + } + }; + + // Initialize map with existing active tabs + var start = function() { + var tabBrowser, tabs, tab; + for ( var win of winWatcher.getWindows() ) { + onWindowLoad(win); + tabBrowser = getTabBrowser(win); + if ( tabBrowser === null ) { + continue; + } + for ( tab of tabBrowser.tabs ) { + if ( !tab.hasAttribute('pending') ) { + tabIdFromTarget(tab); + } + } + } + + winWatcher.onOpenWindow = onWindowLoad; + winWatcher.onCloseWindow = onWindowUnload; + + vAPI.messaging.globalMessageManager.addMessageListener( + locationChangedMessageName, + onLocationChanged + ); + }; + + var stop = function() { + winWatcher.onOpenWindow = null; + winWatcher.onCloseWindow = null; + + vAPI.messaging.globalMessageManager.removeMessageListener( + locationChangedMessageName, + onLocationChanged + ); + + for ( var win of winWatcher.getWindows() ) { + onWindowUnload(win); + } + + browserToTabIdMap = new WeakMap(); + tabIdToBrowserMap.clear(); + }; + + cleanupTasks.push(stop); + + return { + browsers: getAllBrowsers, + browserFromTabId: browserFromTabId, + browserFromTarget: browserFromTarget, + currentBrowser: currentBrowser, + indexFromTarget: indexFromTarget, + removeTarget: removeTarget, + start: start, + tabFromBrowser: tabFromBrowser, + tabIdFromTarget: tabIdFromTarget + }; +})(); + +/******************************************************************************/ + +vAPI.setIcon = function(tabId, iconId, badge) { + // If badge is undefined, then setIcon was called from the TabSelect event + var win; + if ( badge === undefined ) { + win = iconId; + } else { + win = winWatcher.getCurrentWindow(); + } + var tabBrowser = getTabBrowser(win); + if ( tabBrowser === null ) { + return; + } + var curTabId = tabWatcher.tabIdFromTarget(tabBrowser.selectedTab); + var tb = vAPI.toolbarButton; + + // from 'TabSelect' event + if ( tabId === undefined ) { + tabId = curTabId; + } else if ( badge !== undefined ) { + tb.tabs[tabId] = { badge: badge, img: iconId }; + } + + if ( tabId === curTabId ) { + tb.updateState(win, tabId); + } +}; + +/******************************************************************************/ + +vAPI.messaging = { + get globalMessageManager() { + return Cc['@mozilla.org/globalmessagemanager;1'] + .getService(Ci.nsIMessageListenerManager); + }, + frameScript: vAPI.getURL('frameScript.js'), + listeners: {}, + defaultHandler: null, + NOOPFUNC: function(){}, + UNHANDLED: 'vAPI.messaging.notHandled' +}; + +/******************************************************************************/ + +vAPI.messaging.listen = function(listenerName, callback) { + this.listeners[listenerName] = callback; +}; + +/******************************************************************************/ + +vAPI.messaging.onMessage = function({target, data}) { + var messageManager = target.messageManager; + + if ( !messageManager ) { + // Message came from a popup, and its message manager is not usable. + // So instead we broadcast to the parent window. + messageManager = getOwnerWindow( + target.webNavigation.QueryInterface(Ci.nsIDocShell).chromeEventHandler + ).messageManager; + } + + var channelNameRaw = data.channelName; + var pos = channelNameRaw.indexOf('|'); + var channelName = channelNameRaw.slice(pos + 1); + + var callback = vAPI.messaging.NOOPFUNC; + if ( data.requestId !== undefined ) { + callback = CallbackWrapper.factory( + messageManager, + channelName, + channelNameRaw.slice(0, pos), + data.requestId + ).callback; + } + + var sender = { + tab: { + id: tabWatcher.tabIdFromTarget(target) + } + }; + + // Specific handler + var r = vAPI.messaging.UNHANDLED; + var listener = vAPI.messaging.listeners[channelName]; + if ( typeof listener === 'function' ) { + r = listener(data.msg, sender, callback); + } + if ( r !== vAPI.messaging.UNHANDLED ) { + return; + } + + // Default handler + r = vAPI.messaging.defaultHandler(data.msg, sender, callback); + if ( r !== vAPI.messaging.UNHANDLED ) { + return; + } + + console.error('uMatrix> messaging > unknown request: %o', data); + + // Unhandled: + // Need to callback anyways in case caller expected an answer, or + // else there is a memory leak on caller's side + callback(); +}; + +/******************************************************************************/ + +vAPI.messaging.setup = function(defaultHandler) { + // Already setup? + if ( this.defaultHandler !== null ) { + return; + } + + if ( typeof defaultHandler !== 'function' ) { + defaultHandler = function(){ return vAPI.messaging.UNHANDLED; }; + } + this.defaultHandler = defaultHandler; + + this.globalMessageManager.addMessageListener( + location.host + ':background', + this.onMessage + ); + + this.globalMessageManager.loadFrameScript(this.frameScript, true); + + cleanupTasks.push(function() { + var gmm = vAPI.messaging.globalMessageManager; + + gmm.removeDelayedFrameScript(vAPI.messaging.frameScript); + gmm.removeMessageListener( + location.host + ':background', + vAPI.messaging.onMessage + ); + }); +}; + +/******************************************************************************/ + +vAPI.messaging.broadcast = function(message) { + this.globalMessageManager.broadcastAsyncMessage( + location.host + ':broadcast', + JSON.stringify({broadcast: true, msg: message}) + ); +}; + +/******************************************************************************/ + +// This allows to avoid creating a closure for every single message which +// expects an answer. Having a closure created each time a message is processed +// has been always bothering me. Another benefit of the implementation here +// is to reuse the callback proxy object, so less memory churning. +// +// https://developers.google.com/speed/articles/optimizing-javascript +// "Creating a closure is significantly slower then creating an inner +// function without a closure, and much slower than reusing a static +// function" +// +// http://hacksoflife.blogspot.ca/2015/01/the-four-horsemen-of-performance.html +// "the dreaded 'uniformly slow code' case where every function takes 1% +// of CPU and you have to make one hundred separate performance optimizations +// to improve performance at all" +// +// http://jsperf.com/closure-no-closure/2 + +var CallbackWrapper = function(messageManager, channelName, listenerId, requestId) { + this.callback = this.proxy.bind(this); // bind once + this.init(messageManager, channelName, listenerId, requestId); +}; + +CallbackWrapper.junkyard = []; + +CallbackWrapper.factory = function(messageManager, channelName, listenerId, requestId) { + var wrapper = CallbackWrapper.junkyard.pop(); + if ( wrapper ) { + wrapper.init(messageManager, channelName, listenerId, requestId); + return wrapper; + } + return new CallbackWrapper(messageManager, channelName, listenerId, requestId); +}; + +CallbackWrapper.prototype.init = function(messageManager, channelName, listenerId, requestId) { + this.messageManager = messageManager; + this.channelName = channelName; + this.listenerId = listenerId; + this.requestId = requestId; +}; + +CallbackWrapper.prototype.proxy = function(response) { + var message = JSON.stringify({ + requestId: this.requestId, + channelName: this.channelName, + msg: response !== undefined ? response : null + }); + + if ( this.messageManager.sendAsyncMessage ) { + this.messageManager.sendAsyncMessage(this.listenerId, message); + } else { + this.messageManager.broadcastAsyncMessage(this.listenerId, message); + } + + // Mark for reuse + this.messageManager = + this.channelName = + this.requestId = + this.listenerId = null; + CallbackWrapper.junkyard.push(this); +}; + +/******************************************************************************/ + +var httpRequestHeadersFactory = function(channel) { + var entry = httpRequestHeadersFactory.junkyard.pop(); + if ( entry ) { + return entry.init(channel); + } + return new HTTPRequestHeaders(channel); +}; + +httpRequestHeadersFactory.junkyard = []; + +var HTTPRequestHeaders = function(channel) { + this.init(channel); +}; + +HTTPRequestHeaders.prototype.init = function(channel) { + this.channel = channel; + return this; +}; + +HTTPRequestHeaders.prototype.dispose = function() { + this.channel = null; + httpRequestHeadersFactory.junkyard.push(this); +}; + +HTTPRequestHeaders.prototype.getHeader = function(name) { + try { + return this.channel.getRequestHeader(name); + } catch (e) { + } + return ''; +}; + +HTTPRequestHeaders.prototype.setHeader = function(name, newValue, create) { + var oldValue = this.getHeader(name); + if ( newValue === oldValue ) { + return false; + } + if ( oldValue === '' && create !== true ) { + return false; + } + this.channel.setRequestHeader(name, newValue, false); + return true; +}; + +/******************************************************************************/ + +var httpObserver = { + classDescription: 'net-channel-event-sinks for ' + location.host, + classID: Components.ID('{5d2e2797-6d68-42e2-8aeb-81ce6ba16b95}'), + contractID: '@' + location.host + '/net-channel-event-sinks;1', + REQDATAKEY: location.host + 'reqdata', + ABORT: Components.results.NS_BINDING_ABORTED, + ACCEPT: Components.results.NS_SUCCEEDED, + // Request types: + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIContentPolicy#Constants + frameTypeMap: { + 6: 'main_frame', + 7: 'sub_frame' + }, + typeMap: { + 1: 'other', + 2: 'script', + 3: 'image', + 4: 'stylesheet', + 5: 'object', + 6: 'main_frame', + 7: 'sub_frame', + 9: 'xbl', + 10: 'ping', + 11: 'xmlhttprequest', + 12: 'object', + 13: 'xml_dtd', + 14: 'font', + 15: 'media', + 16: 'websocket', + 17: 'csp_report', + 18: 'xslt', + 19: 'beacon', + 20: 'xmlhttprequest', + 21: 'imageset', + 22: 'web_manifest' + }, + mimeTypeMap: { + 'audio': 15, + 'video': 15 + }, + + get componentRegistrar() { + return Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + }, + + get categoryManager() { + return Cc['@mozilla.org/categorymanager;1'] + .getService(Ci.nsICategoryManager); + }, + + QueryInterface: (function() { + var {XPCOMUtils} = Cu.import('resource://gre/modules/XPCOMUtils.jsm', null); + + return XPCOMUtils.generateQI([ + Ci.nsIFactory, + Ci.nsIObserver, + Ci.nsIChannelEventSink, + Ci.nsISupportsWeakReference + ]); + })(), + + createInstance: function(outer, iid) { + if ( outer ) { + throw Components.results.NS_ERROR_NO_AGGREGATION; + } + + return this.QueryInterface(iid); + }, + + register: function() { + this.pendingRingBufferInit(); + + // https://developer.mozilla.org/en/docs/Observer_Notifications#HTTP_requests + Services.obs.addObserver(this, 'http-on-modify-request', true); + Services.obs.addObserver(this, 'http-on-examine-response', true); + Services.obs.addObserver(this, 'http-on-examine-cached-response', true); + + // Guard against stale instances not having been unregistered + if ( this.componentRegistrar.isCIDRegistered(this.classID) ) { + try { + this.componentRegistrar.unregisterFactory(this.classID, Components.manager.getClassObject(this.classID, Ci.nsIFactory)); + } catch (ex) { + console.error('uMatrix> httpObserver > unable to unregister stale instance: ', ex); + } + } + + this.componentRegistrar.registerFactory( + this.classID, + this.classDescription, + this.contractID, + this + ); + this.categoryManager.addCategoryEntry( + 'net-channel-event-sinks', + this.contractID, + this.contractID, + false, + true + ); + }, + + unregister: function() { + Services.obs.removeObserver(this, 'http-on-modify-request'); + Services.obs.removeObserver(this, 'http-on-examine-response'); + Services.obs.removeObserver(this, 'http-on-examine-cached-response'); + + this.componentRegistrar.unregisterFactory(this.classID, this); + this.categoryManager.deleteCategoryEntry( + 'net-channel-event-sinks', + this.contractID, + false + ); + }, + + PendingRequest: function() { + this.rawType = 0; + this.tabId = 0; + this._key = ''; // key is url, from URI.spec + }, + + // If all work fine, this map should not grow indefinitely. It can have + // stale items in it, but these will be taken care of when entries in + // the ring buffer are overwritten. + pendingURLToIndex: new Map(), + pendingWritePointer: 0, + pendingRingBuffer: new Array(256), + pendingRingBufferInit: function() { + // Use and reuse pre-allocated PendingRequest objects = less memory + // churning. + var i = this.pendingRingBuffer.length; + while ( i-- ) { + this.pendingRingBuffer[i] = new this.PendingRequest(); + } + }, + + // Pending request ring buffer: + // +-------+-------+-------+-------+-------+-------+------- + // |0 |1 |2 |3 |4 |5 |... + // +-------+-------+-------+-------+-------+-------+------- + // + // URL to ring buffer index map: + // { k = URL, s = ring buffer indices } + // + // s is a string which character codes map to ring buffer indices -- for + // when the same URL is received multiple times by shouldLoadListener() + // before the existing one is serviced by the network request observer. + // I believe the use of a string in lieu of an array reduces memory + // churning. + + createPendingRequest: function(url) { + var bucket; + var i = this.pendingWritePointer; + this.pendingWritePointer = i + 1 & 255; + var preq = this.pendingRingBuffer[i]; + var si = String.fromCharCode(i); + // Cleanup unserviced pending request + if ( preq._key !== '' ) { + bucket = this.pendingURLToIndex.get(preq._key); + if ( bucket.length === 1 ) { + this.pendingURLToIndex.delete(preq._key); + } else { + var pos = bucket.indexOf(si); + this.pendingURLToIndex.set(preq._key, bucket.slice(0, pos) + bucket.slice(pos + 1)); + } + } + bucket = this.pendingURLToIndex.get(url); + this.pendingURLToIndex.set(url, bucket === undefined ? si : bucket + si); + preq._key = url; + return preq; + }, + + lookupPendingRequest: function(url) { + var bucket = this.pendingURLToIndex.get(url); + if ( bucket === undefined ) { + return null; + } + var i = bucket.charCodeAt(0); + if ( bucket.length === 1 ) { + this.pendingURLToIndex.delete(url); + } else { + this.pendingURLToIndex.set(url, bucket.slice(1)); + } + var preq = this.pendingRingBuffer[i]; + preq._key = ''; // mark as "serviced" + return preq; + }, + + handleRequest: function(channel, URI, tabId, rawType) { + var type = this.typeMap[rawType] || 'other'; + + var onBeforeRequest = vAPI.net.onBeforeRequest; + if ( onBeforeRequest.types === null || onBeforeRequest.types.has(type) ) { + var result = onBeforeRequest.callback({ + parentFrameId: type === 'main_frame' ? -1 : 0, + tabId: tabId, + type: type, + url: URI.asciiSpec + }); + if ( typeof result === 'object' ) { + channel.cancel(this.ABORT); + return true; + } + } + + var onBeforeSendHeaders = vAPI.net.onBeforeSendHeaders; + if ( onBeforeSendHeaders.types === null || onBeforeSendHeaders.types.has(type) ) { + var requestHeaders = httpRequestHeadersFactory(channel); + onBeforeSendHeaders.callback({ + parentFrameId: type === 'main_frame' ? -1 : 0, + requestHeaders: requestHeaders, + tabId: tabId, + type: type, + url: URI.asciiSpec + }); + requestHeaders.dispose(); + } + + return false; + }, + + channelDataFromChannel: function(channel) { + if ( channel instanceof Ci.nsIWritablePropertyBag ) { + try { + return channel.getProperty(this.REQDATAKEY) || null; + } catch (ex) { + } + } + return null; + }, + + // https://github.com/gorhill/uMatrix/issues/165 + // https://developer.mozilla.org/en-US/Firefox/Releases/3.5/Updating_extensions#Getting_a_load_context_from_a_request + // Not sure `umatrix:shouldLoad` is still needed, uMatrix does not + // care about embedded frames topography. + // Also: + // https://developer.mozilla.org/en-US/Firefox/Multiprocess_Firefox/Limitations_of_chrome_scripts + tabIdFromChannel: function(channel) { + var lc; + try { + lc = channel.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch(ex) { + } + if ( !lc ) { + try { + lc = channel.loadGroup.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch(ex) { + } + if ( !lc ) { + return vAPI.noTabId; + } + } + if ( lc.topFrameElement ) { + return tabWatcher.tabIdFromTarget(lc.topFrameElement); + } + var win; + try { + win = lc.associatedWindow; + } catch (ex) { } + if ( !win ) { + return vAPI.noTabId; + } + if ( win.top ) { + win = win.top; + } + var tabBrowser; + try { + tabBrowser = getTabBrowser( + win.QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIWebNavigation) + .QueryInterface(Ci.nsIDocShell).rootTreeItem + .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsIDOMWindow) + ); + } catch (ex) { } + if ( !tabBrowser ) { + return vAPI.noTabId; + } + if ( tabBrowser.getBrowserForContentWindow ) { + return tabWatcher.tabIdFromTarget(tabBrowser.getBrowserForContentWindow(win)); + } + // Falling back onto _getTabForContentWindow to ensure older versions + // of Firefox work well. + return tabBrowser._getTabForContentWindow ? + tabWatcher.tabIdFromTarget(tabBrowser._getTabForContentWindow(win)) : + vAPI.noTabId; + }, + + rawtypeFromContentType: function(channel) { + var mime = channel.contentType; + if ( !mime ) { + return 0; + } + var pos = mime.indexOf('/'); + if ( pos === -1 ) { + pos = mime.length; + } + return this.mimeTypeMap[mime.slice(0, pos)] || 0; + }, + + observe: function(channel, topic) { + if ( channel instanceof Ci.nsIHttpChannel === false ) { + return; + } + + var URI = channel.URI; + var channelData = this.channelDataFromChannel(channel); + + if ( topic.lastIndexOf('http-on-examine-', 0) === 0 ) { + if ( channelData === null ) { + return; + } + + var type = this.frameTypeMap[channelData[1]]; + if ( !type ) { + return; + } + + topic = 'Content-Security-Policy'; + + var result; + try { + result = channel.getResponseHeader(topic); + } catch (ex) { + result = null; + } + + result = vAPI.net.onHeadersReceived.callback({ + parentFrameId: type === 'main_frame' ? -1 : 0, + responseHeaders: result ? [{name: topic, value: result}] : [], + tabId: channelData[0], + type: type, + url: URI.asciiSpec + }); + + if ( result ) { + channel.setResponseHeader( + topic, + result.responseHeaders.pop().value, + true + ); + } + + return; + } + + // http-on-modify-request + + // The channel was previously serviced. + if ( channelData !== null ) { + this.handleRequest(channel, URI, channelData[0], channelData[1]); + return; + } + + // The channel was never serviced. + var tabId; + var pendingRequest = this.lookupPendingRequest(URI.asciiSpec); + var rawType = 1; + var loadInfo = channel.loadInfo; + + // https://github.com/gorhill/uMatrix/issues/390#issuecomment-155717004 + if ( loadInfo ) { + rawType = loadInfo.externalContentPolicyType !== undefined ? + loadInfo.externalContentPolicyType : + loadInfo.contentPolicyType; + if ( !rawType ) { + rawType = 1; + } + } + + if ( pendingRequest !== null ) { + tabId = pendingRequest.tabId; + // https://github.com/gorhill/uBlock/issues/654 + // Use the request type from the HTTP observer point of view. + if ( rawType !== 1 ) { + pendingRequest.rawType = rawType; + } else { + rawType = pendingRequest.rawType; + } + } else { + tabId = this.tabIdFromChannel(channel); + } + + if ( this.handleRequest(channel, URI, tabId, rawType) ) { + return; + } + + if ( channel instanceof Ci.nsIWritablePropertyBag === false ) { + return; + } + + // Carry data for behind-the-scene redirects + channel.setProperty(this.REQDATAKEY, [tabId, rawType]); + }, + + // contentPolicy.shouldLoad doesn't detect redirects, this needs to be used + asyncOnChannelRedirect: function(oldChannel, newChannel, flags, callback) { + // If error thrown, the redirect will fail + try { + var URI = newChannel.URI; + if ( !URI.schemeIs('http') && !URI.schemeIs('https') ) { + return; + } + + if ( newChannel instanceof Ci.nsIWritablePropertyBag === false ) { + return; + } + + var channelData = this.channelDataFromChannel(oldChannel); + if ( channelData === null ) { + return; + } + + // Carry the data on in case of multiple redirects + newChannel.setProperty(this.REQDATAKEY, channelData); + } catch (ex) { + // console.error(ex); + } finally { + callback.onRedirectVerifyCallback(this.ACCEPT); + } + } +}; + +/******************************************************************************/ + +vAPI.net = {}; + +/******************************************************************************/ + +vAPI.net.registerListeners = function() { + this.onBeforeRequest.types = this.onBeforeRequest.types ? + new Set(this.onBeforeRequest.types) : + null; + this.onBeforeSendHeaders.types = this.onBeforeSendHeaders.types ? + new Set(this.onBeforeSendHeaders.types) : + null; + + var shouldLoadListenerMessageName = location.host + ':shouldLoad'; + var shouldLoadListener = function(e) { + var details = e.data; + var pendingReq = httpObserver.createPendingRequest(details.url); + pendingReq.rawType = details.rawType; + pendingReq.tabId = tabWatcher.tabIdFromTarget(e.target); + }; + + // https://github.com/gorhill/uMatrix/issues/200 + // We need this only for Firefox 34 and less: the tab id is derived from + // the origin of the message. + if ( !vAPI.modernFirefox ) { + vAPI.messaging.globalMessageManager.addMessageListener( + shouldLoadListenerMessageName, + shouldLoadListener + ); + } + + httpObserver.register(); + + cleanupTasks.push(function() { + if ( !vAPI.modernFirefox ) { + vAPI.messaging.globalMessageManager.removeMessageListener( + shouldLoadListenerMessageName, + shouldLoadListener + ); + } + + httpObserver.unregister(); + }); +}; + +/******************************************************************************/ +/******************************************************************************/ + +vAPI.toolbarButton = { + id: location.host + '-button', + type: 'view', + viewId: location.host + '-panel', + label: vAPI.app.name, + tooltiptext: vAPI.app.name, + tabs: {/*tabId: {badge: 0, img: boolean}*/}, + init: null, + codePath: '' +}; + +/******************************************************************************/ + +// Non-Fennec: common code paths. + +(function() { + if ( vAPI.fennec ) { + return; + } + + var tbb = vAPI.toolbarButton; + var popupCommittedWidth = 0; + var popupCommittedHeight = 0; + + tbb.onViewShowing = function({target}) { + popupCommittedWidth = popupCommittedHeight = 0; + target.firstChild.setAttribute('src', vAPI.getURL('popup.html')); + }; + + tbb.onViewHiding = function({target}) { + target.parentNode.style.maxWidth = ''; + target.firstChild.setAttribute('src', 'about:blank'); + }; + + tbb.updateState = function(win, tabId) { + var button = win.document.getElementById(this.id); + + if ( !button ) { + return; + } + + var icon = this.tabs[tabId]; + button.setAttribute('badge', icon && icon.badge || ''); + button.classList.toggle('off', !icon || !icon.img); + + var iconId = icon && icon.img ? icon.img : 'off'; + icon = 'url(' + vAPI.getURL('img/browsericons/icon19-' + iconId + '.png') + ')'; + button.style.listStyleImage = icon; + }; + + tbb.populatePanel = function(doc, panel) { + panel.setAttribute('id', this.viewId); + + var iframe = doc.createElement('iframe'); + iframe.setAttribute('type', 'content'); + + panel.appendChild(iframe); + + var toPx = function(pixels) { + return pixels.toString() + 'px'; + }; + + var scrollBarWidth = 0; + var resizeTimer = null; + + var resizePopupDelayed = function(attempts) { + if ( resizeTimer !== null ) { + return false; + } + + // Sanity check + attempts = (attempts || 0) + 1; + if ( attempts > 1/*000*/ ) { + //console.error('uMatrix> resizePopupDelayed: giving up after too many attempts'); + return false; + } + + resizeTimer = vAPI.setTimeout(resizePopup, 10, attempts); + return true; + }; + + var resizePopup = function(attempts) { + resizeTimer = null; + + panel.parentNode.style.maxWidth = 'none'; + var body = iframe.contentDocument.body; + + // https://github.com/gorhill/uMatrix/issues/301 + // Don't resize if committed size did not change. + if ( + popupCommittedWidth === body.clientWidth && + popupCommittedHeight === body.clientHeight + ) { + return; + } + + // We set a limit for height + var height = Math.min(body.clientHeight, 600); + + // https://github.com/chrisaljoudi/uBlock/issues/730 + // Voodoo programming: this recipe works + panel.style.setProperty('height', toPx(height)); + iframe.style.setProperty('height', toPx(height)); + + // Adjust width for presence/absence of vertical scroll bar which may + // have appeared as a result of last operation. + var contentWindow = iframe.contentWindow; + var width = body.clientWidth; + if ( contentWindow.scrollMaxY !== 0 ) { + width += scrollBarWidth; + } + panel.style.setProperty('width', toPx(width)); + + // scrollMaxX should always be zero once we know the scrollbar width + if ( contentWindow.scrollMaxX !== 0 ) { + scrollBarWidth = contentWindow.scrollMaxX; + width += scrollBarWidth; + panel.style.setProperty('width', toPx(width)); + } + + if ( iframe.clientHeight !== height || panel.clientWidth !== width ) { + if ( resizePopupDelayed(attempts) ) { + return; + } + // resizePopupDelayed won't be called again, so commit + // dimentsions. + } + + popupCommittedWidth = body.clientWidth; + popupCommittedHeight = body.clientHeight; + }; + + var onResizeRequested = function() { + var body = iframe.contentDocument.body; + if ( body.getAttribute('data-resize-popup') !== 'true' ) { + return; + } + body.removeAttribute('data-resize-popup'); + resizePopupDelayed(); + }; + + var onPopupReady = function() { + var win = this.contentWindow; + + if ( !win || win.location.host !== location.host ) { + return; + } + + if ( typeof tbb.onBeforePopupReady === 'function' ) { + tbb.onBeforePopupReady.call(this); + } + + resizePopupDelayed(); + + var body = win.document.body; + body.removeAttribute('data-resize-popup'); + var mutationObserver = new win.MutationObserver(onResizeRequested); + mutationObserver.observe(body, { + attributes: true, + attributeFilter: [ 'data-resize-popup' ] + }); + }; + + iframe.addEventListener('load', onPopupReady, true); + }; +})(); + +/******************************************************************************/ + +// Firefox 28 and less + +(function() { + var tbb = vAPI.toolbarButton; + if ( tbb.init !== null ) { + return; + } + var CustomizableUI = null; + var forceLegacyToolbarButton = vAPI.localStorage.getBool('forceLegacyToolbarButton'); + if ( !forceLegacyToolbarButton ) { + try { + CustomizableUI = Cu.import('resource:///modules/CustomizableUI.jsm', null).CustomizableUI; + } catch (ex) { + } + } + if ( CustomizableUI !== null ) { + return; + } + + tbb.codePath = 'legacy'; + tbb.id = 'umatrix-legacy-button'; // NOTE: must match legacy-toolbar-button.css + tbb.viewId = tbb.id + '-panel'; + + var styleSheetUri = null; + + var createToolbarButton = function(window) { + var document = window.document; + + var toolbarButton = document.createElement('toolbarbutton'); + toolbarButton.setAttribute('id', tbb.id); + // type = panel would be more accurate, but doesn't look as good + toolbarButton.setAttribute('type', 'menu'); + toolbarButton.setAttribute('removable', 'true'); + toolbarButton.setAttribute('class', 'toolbarbutton-1 chromeclass-toolbar-additional'); + toolbarButton.setAttribute('label', tbb.label); + toolbarButton.setAttribute('tooltiptext', tbb.label); + + var toolbarButtonPanel = document.createElement('panel'); + // NOTE: Setting level to parent breaks the popup for PaleMoon under + // linux (mouse pointer misaligned with content). For some reason. + // toolbarButtonPanel.setAttribute('level', 'parent'); + tbb.populatePanel(document, toolbarButtonPanel); + toolbarButtonPanel.addEventListener('popupshowing', tbb.onViewShowing); + toolbarButtonPanel.addEventListener('popuphiding', tbb.onViewHiding); + toolbarButton.appendChild(toolbarButtonPanel); + + return toolbarButton; + }; + + var addLegacyToolbarButton = function(window) { + // uMatrix's stylesheet lazily added. + if ( styleSheetUri === null ) { + var sss = Cc["@mozilla.org/content/style-sheet-service;1"] + .getService(Ci.nsIStyleSheetService); + styleSheetUri = Services.io.newURI(vAPI.getURL("css/legacy-toolbar-button.css"), null, null); + + // Register global so it works in all windows, including palette + if ( !sss.sheetRegistered(styleSheetUri, sss.AUTHOR_SHEET) ) { + sss.loadAndRegisterSheet(styleSheetUri, sss.AUTHOR_SHEET); + } + } + + var document = window.document; + + // https://github.com/gorhill/uMatrix/issues/357 + // Already installed? + if ( document.getElementById(tbb.id) !== null ) { + return; + } + + var toolbox = document.getElementById('navigator-toolbox') || + document.getElementById('mail-toolbox'); + if ( toolbox === null ) { + return; + } + + var toolbarButton = createToolbarButton(window); + + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/toolbarpalette + var palette = toolbox.palette; + if ( palette && palette.querySelector('#' + tbb.id) === null ) { + palette.appendChild(toolbarButton); + } + + // Find the place to put the button. + // Pale Moon: `toolbox.externalToolbars` can be undefined. Seen while + // testing popup test number 3: + // http://raymondhill.net/ublock/popup.html + var toolbars = toolbox.externalToolbars ? toolbox.externalToolbars.slice() : []; + for ( var child of toolbox.children ) { + if ( child.localName === 'toolbar' ) { + toolbars.push(child); + } + } + + for ( var toolbar of toolbars ) { + var currentsetString = toolbar.getAttribute('currentset'); + if ( !currentsetString ) { + continue; + } + var currentset = currentsetString.split(/\s*,\s*/); + var index = currentset.indexOf(tbb.id); + if ( index === -1 ) { + continue; + } + // This can occur with Pale Moon: + // "TypeError: toolbar.insertItem is not a function" + if ( typeof toolbar.insertItem !== 'function' ) { + continue; + } + // Found our button on this toolbar - but where on it? + var before = null; + for ( var i = index + 1; i < currentset.length; i++ ) { + before = toolbar.querySelector('[id="' + currentset[i] + '"]'); + if ( before !== null ) { + break; + } + } + // https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XUL/Method/insertItem + toolbar.insertItem(tbb.id, before); + break; + } + + // https://github.com/gorhill/uBlock/issues/763 + // We are done if our toolbar button is already installed in one of the + // toolbar. + if ( palette !== null && toolbarButton.parentElement !== palette ) { + return; + } + + // No button yet so give it a default location. If forcing the button, + // just put in in the palette rather than on any specific toolbar (who + // knows what toolbars will be available or visible!) + var navbar = document.getElementById('nav-bar'); + if ( navbar !== null && !vAPI.localStorage.getBool('legacyToolbarButtonAdded') ) { + // https://github.com/gorhill/uBlock/issues/264 + // Find a child customizable palette, if any. + navbar = navbar.querySelector('.customization-target') || navbar; + navbar.appendChild(toolbarButton); + navbar.setAttribute('currentset', navbar.currentSet); + document.persist(navbar.id, 'currentset'); + vAPI.localStorage.setBool('legacyToolbarButtonAdded', 'true'); + } + }; + + var canAddLegacyToolbarButton = function(window) { + var document = window.document; + if ( + !document || + document.readyState !== 'complete' || + document.getElementById('nav-bar') === null + ) { + return false; + } + var toolbox = document.getElementById('navigator-toolbox') || + document.getElementById('mail-toolbox'); + return toolbox !== null && !!toolbox.palette; + }; + + var onPopupCloseRequested = function({target}) { + var document = target.ownerDocument; + if ( !document ) { + return; + } + var toolbarButtonPanel = document.getElementById(tbb.viewId); + if ( toolbarButtonPanel === null ) { + return; + } + // `hidePopup` reported as not existing while testing legacy button + // on FF 41.0.2. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1151796 + if ( typeof toolbarButtonPanel.hidePopup === 'function' ) { + toolbarButtonPanel.hidePopup(); + } + }; + + var shutdown = function() { + for ( var win of winWatcher.getWindows() ) { + var toolbarButton = win.document.getElementById(tbb.id); + if ( toolbarButton ) { + toolbarButton.parentNode.removeChild(toolbarButton); + } + } + + if ( styleSheetUri !== null ) { + var sss = Cc["@mozilla.org/content/style-sheet-service;1"] + .getService(Ci.nsIStyleSheetService); + if ( sss.sheetRegistered(styleSheetUri, sss.AUTHOR_SHEET) ) { + sss.unregisterSheet(styleSheetUri, sss.AUTHOR_SHEET); + } + styleSheetUri = null; + } + + vAPI.messaging.globalMessageManager.removeMessageListener( + location.host + ':closePopup', + onPopupCloseRequested + ); + }; + + tbb.attachToNewWindow = function(win) { + deferUntil( + canAddLegacyToolbarButton.bind(null, win), + addLegacyToolbarButton.bind(null, win) + ); + }; + + tbb.init = function() { + vAPI.messaging.globalMessageManager.addMessageListener( + location.host + ':closePopup', + onPopupCloseRequested + ); + + cleanupTasks.push(shutdown); + }; +})(); + +/******************************************************************************/ + +// Firefox Australis < 36. + +(function() { + var tbb = vAPI.toolbarButton; + if ( tbb.init !== null ) { + return; + } + if ( Services.vc.compare(Services.appinfo.platformVersion, '36.0') >= 0 ) { + return null; + } + if ( vAPI.localStorage.getBool('forceLegacyToolbarButton') ) { + return null; + } + var CustomizableUI = null; + try { + CustomizableUI = Cu.import('resource:///modules/CustomizableUI.jsm', null).CustomizableUI; + } catch (ex) { + } + if ( CustomizableUI === null ) { + return; + } + tbb.codePath = 'australis'; + tbb.CustomizableUI = CustomizableUI; + tbb.defaultArea = CustomizableUI.AREA_NAVBAR; + + var styleURI = null; + + var onPopupCloseRequested = function({target}) { + if ( typeof tbb.closePopup === 'function' ) { + tbb.closePopup(target); + } + }; + + var shutdown = function() { + CustomizableUI.destroyWidget(tbb.id); + + for ( var win of winWatcher.getWindows() ) { + var panel = win.document.getElementById(tbb.viewId); + panel.parentNode.removeChild(panel); + win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .removeSheet(styleURI, 1); + } + + vAPI.messaging.globalMessageManager.removeMessageListener( + location.host + ':closePopup', + onPopupCloseRequested + ); + }; + + tbb.onBeforeCreated = function(doc) { + var panel = doc.createElement('panelview'); + + this.populatePanel(doc, panel); + + doc.getElementById('PanelUI-multiView').appendChild(panel); + + doc.defaultView.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .loadSheet(styleURI, 1); + }; + + tbb.onBeforePopupReady = function() { + // https://github.com/gorhill/uBlock/issues/83 + // Add `portrait` class if width is constrained. + try { + this.contentDocument.body.classList.toggle( + 'portrait', + CustomizableUI.getWidget(tbb.id).areaType === CustomizableUI.TYPE_MENU_PANEL + ); + } catch (ex) { + /* noop */ + } + }; + + tbb.init = function() { + vAPI.messaging.globalMessageManager.addMessageListener( + location.host + ':closePopup', + onPopupCloseRequested + ); + + var style = [ + '#' + this.id + '.off {', + 'list-style-image: url(', + vAPI.getURL('img/browsericons/icon19-off.png'), + ');', + '}', + '#' + this.id + ' {', + 'list-style-image: url(', + vAPI.getURL('img/browsericons/icon19.png'), + ');', + '}', + '#' + this.viewId + ', #' + this.viewId + ' > iframe {', + 'width: 160px;', + 'height: 290px;', + 'overflow: hidden !important;', + '}', + '#' + this.id + '[badge]:not([badge=""])::after {', + 'position: absolute;', + 'margin-left: -16px;', + 'margin-top: 3px;', + 'padding: 1px 2px;', + 'font-size: 9px;', + 'font-weight: bold;', + 'color: #fff;', + 'background: #000;', + 'content: attr(badge);', + '}' + ]; + + styleURI = Services.io.newURI( + 'data:text/css,' + encodeURIComponent(style.join('')), + null, + null + ); + + this.closePopup = function(tabBrowser) { + CustomizableUI.hidePanelForNode( + tabBrowser.ownerDocument.getElementById(this.viewId) + ); + }; + + CustomizableUI.createWidget(this); + + cleanupTasks.push(shutdown); + }; +})(); + +/******************************************************************************/ + +// Firefox Australis >= 36. + +(function() { + var tbb = vAPI.toolbarButton; + if ( tbb.init !== null ) { + return; + } + if ( Services.vc.compare(Services.appinfo.platformVersion, '36.0') < 0 ) { + return null; + } + if ( vAPI.localStorage.getBool('forceLegacyToolbarButton') ) { + return null; + } + var CustomizableUI = null; + try { + CustomizableUI = Cu.import('resource:///modules/CustomizableUI.jsm', null).CustomizableUI; + } catch (ex) { + } + if ( CustomizableUI === null ) { + return null; + } + tbb.codePath = 'australis'; + tbb.CustomizableUI = CustomizableUI; + tbb.defaultArea = CustomizableUI.AREA_NAVBAR; + + var CUIEvents = {}; + + var badgeCSSRules = [ + 'background: #000', + 'color: #fff' + ].join(';'); + + var updateBadgeStyle = function() { + for ( var win of winWatcher.getWindows() ) { + var button = win.document.getElementById(tbb.id); + if ( button === null ) { + continue; + } + var badge = button.ownerDocument.getAnonymousElementByAttribute( + button, + 'class', + 'toolbarbutton-badge' + ); + if ( !badge ) { + continue; + } + + badge.style.cssText = badgeCSSRules; + } + }; + + var updateBadge = function() { + var wId = tbb.id; + var buttonInPanel = CustomizableUI.getWidget(wId).areaType === CustomizableUI.TYPE_MENU_PANEL; + + for ( var win of winWatcher.getWindows() ) { + var button = win.document.getElementById(wId); + if ( button === null ) { + continue; + } + if ( buttonInPanel ) { + button.classList.remove('badged-button'); + continue; + } + button.classList.add('badged-button'); + } + + if ( buttonInPanel ) { + return; + } + + // Anonymous elements need some time to be reachable + vAPI.setTimeout(updateBadgeStyle, 250); + }.bind(CUIEvents); + + // https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/CustomizableUI.jsm#Listeners + CUIEvents.onCustomizeEnd = updateBadge; + CUIEvents.onWidgetAdded = updateBadge; + CUIEvents.onWidgetUnderflow = updateBadge; + + var onPopupCloseRequested = function({target}) { + if ( typeof tbb.closePopup === 'function' ) { + tbb.closePopup(target); + } + }; + + var shutdown = function() { + for ( var win of winWatcher.getWindows() ) { + var panel = win.document.getElementById(tbb.viewId); + if ( panel !== null && panel.parentNode !== null ) { + panel.parentNode.removeChild(panel); + } + win.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .removeSheet(styleURI, 1); + } + + CustomizableUI.removeListener(CUIEvents); + CustomizableUI.destroyWidget(tbb.id); + + vAPI.messaging.globalMessageManager.removeMessageListener( + location.host + ':closePopup', + onPopupCloseRequested + ); + }; + + var styleURI = null; + + tbb.onBeforeCreated = function(doc) { + var panel = doc.createElement('panelview'); + + this.populatePanel(doc, panel); + + doc.getElementById('PanelUI-multiView').appendChild(panel); + + doc.defaultView.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindowUtils) + .loadSheet(styleURI, 1); + }; + + tbb.onCreated = function(button) { + button.setAttribute('badge', ''); + vAPI.setTimeout(updateBadge, 250); + }; + + tbb.onBeforePopupReady = function() { + // https://github.com/gorhill/uBlock/issues/83 + // Add `portrait` class if width is constrained. + try { + this.contentDocument.body.classList.toggle( + 'portrait', + CustomizableUI.getWidget(tbb.id).areaType === CustomizableUI.TYPE_MENU_PANEL + ); + } catch (ex) { + /* noop */ + } + }; + + tbb.closePopup = function(tabBrowser) { + CustomizableUI.hidePanelForNode( + tabBrowser.ownerDocument.getElementById(tbb.viewId) + ); + }; + + tbb.init = function() { + vAPI.messaging.globalMessageManager.addMessageListener( + location.host + ':closePopup', + onPopupCloseRequested + ); + + CustomizableUI.addListener(CUIEvents); + + var style = [ + '#' + this.id + '.off {', + 'list-style-image: url(', + vAPI.getURL('img/browsericons/icon19-off.png'), + ');', + '}', + '#' + this.id + ' {', + 'list-style-image: url(', + vAPI.getURL('img/browsericons/icon19-19.png'), + ');', + '}', + '#' + this.viewId + ', #' + this.viewId + ' > iframe {', + 'height: 290px;', + 'max-width: none !important;', + 'min-width: 0 !important;', + 'overflow: hidden !important;', + 'padding: 0 !important;', + 'width: 160px;', + '}' + ]; + + styleURI = Services.io.newURI( + 'data:text/css,' + encodeURIComponent(style.join('')), + null, + null + ); + + CustomizableUI.createWidget(this); + + cleanupTasks.push(shutdown); + }; +})(); + +/******************************************************************************/ + +// No toolbar button. + +(function() { + // Just to ensure the number of cleanup tasks is as expected: toolbar + // button code is one single cleanup task regardless of platform. + if ( vAPI.toolbarButton.init === null ) { + cleanupTasks.push(function(){}); + } +})(); + +/******************************************************************************/ + +if ( vAPI.toolbarButton.init !== null ) { + vAPI.toolbarButton.init(); +} + +/******************************************************************************/ +/******************************************************************************/ + +vAPI.contextMenu = { + contextMap: { + frame: 'inFrame', + link: 'onLink', + image: 'onImage', + audio: 'onAudio', + video: 'onVideo', + editable: 'onEditableArea' + } +}; + +/******************************************************************************/ + +vAPI.contextMenu.displayMenuItem = function({target}) { + var doc = target.ownerDocument; + var gContextMenu = doc.defaultView.gContextMenu; + if ( !gContextMenu.browser ) { + return; + } + + var menuitem = doc.getElementById(vAPI.contextMenu.menuItemId); + var currentURI = gContextMenu.browser.currentURI; + + // https://github.com/chrisaljoudi/uBlock/issues/105 + // TODO: Should the element picker works on any kind of pages? + if ( !currentURI.schemeIs('http') && !currentURI.schemeIs('https') ) { + menuitem.setAttribute('hidden', true); + return; + } + + var ctx = vAPI.contextMenu.contexts; + + if ( !ctx ) { + menuitem.setAttribute('hidden', false); + return; + } + + var ctxMap = vAPI.contextMenu.contextMap; + + for ( var context of ctx ) { + if ( + context === 'page' && + !gContextMenu.onLink && + !gContextMenu.onImage && + !gContextMenu.onEditableArea && + !gContextMenu.inFrame && + !gContextMenu.onVideo && + !gContextMenu.onAudio + ) { + menuitem.setAttribute('hidden', false); + return; + } + + if ( + ctxMap.hasOwnProperty(context) && + gContextMenu[ctxMap[context]] + ) { + menuitem.setAttribute('hidden', false); + return; + } + } + + menuitem.setAttribute('hidden', true); +}; + +/******************************************************************************/ + +vAPI.contextMenu.register = (function() { + var register = function(doc) { + if ( !this.menuItemId ) { + return; + } + + // Already installed? + if ( doc.getElementById(this.menuItemId) !== null ) { + return; + } + + var contextMenu = doc.getElementById('contentAreaContextMenu'); + var menuitem = doc.createElement('menuitem'); + menuitem.setAttribute('id', this.menuItemId); + menuitem.setAttribute('label', this.menuLabel); + menuitem.setAttribute('image', vAPI.getURL('img/browsericons/icon19-19.png')); + menuitem.setAttribute('class', 'menuitem-iconic'); + menuitem.addEventListener('command', this.onCommand); + contextMenu.addEventListener('popupshowing', this.displayMenuItem); + contextMenu.insertBefore(menuitem, doc.getElementById('inspect-separator')); + }; + + // https://github.com/gorhill/uBlock/issues/906 + // Be sure document.readyState is 'complete': it could happen at launch + // time that we are called by vAPI.contextMenu.create() directly before + // the environment is properly initialized. + var registerSafely = function(doc, tryCount) { + if ( doc.readyState === 'complete' ) { + register.call(this, doc); + return; + } + if ( typeof tryCount !== 'number' ) { + tryCount = 0; + } + tryCount += 1; + if ( tryCount < 8 ) { + vAPI.setTimeout(registerSafely.bind(this, doc, tryCount), 200); + } + }; + + return registerSafely; +})(); + +/******************************************************************************/ + +vAPI.contextMenu.unregister = function(doc) { + if ( !this.menuItemId ) { + return; + } + + var menuitem = doc.getElementById(this.menuItemId); + if ( menuitem === null ) { + return; + } + var contextMenu = menuitem.parentNode; + menuitem.removeEventListener('command', this.onCommand); + contextMenu.removeEventListener('popupshowing', this.displayMenuItem); + contextMenu.removeChild(menuitem); +}; + +/******************************************************************************/ + +vAPI.contextMenu.create = function(details, callback) { + this.menuItemId = details.id; + this.menuLabel = details.title; + this.contexts = details.contexts; + + if ( Array.isArray(this.contexts) && this.contexts.length ) { + this.contexts = this.contexts.indexOf('all') === -1 ? this.contexts : null; + } else { + // default in Chrome + this.contexts = ['page']; + } + + this.onCommand = function() { + var gContextMenu = getOwnerWindow(this).gContextMenu; + var details = { + menuItemId: this.id + }; + + if ( gContextMenu.inFrame ) { + details.tagName = 'iframe'; + // Probably won't work with e10s + details.frameUrl = gContextMenu.focusedWindow.location.href; + } else if ( gContextMenu.onImage ) { + details.tagName = 'img'; + details.srcUrl = gContextMenu.mediaURL; + } else if ( gContextMenu.onAudio ) { + details.tagName = 'audio'; + details.srcUrl = gContextMenu.mediaURL; + } else if ( gContextMenu.onVideo ) { + details.tagName = 'video'; + details.srcUrl = gContextMenu.mediaURL; + } else if ( gContextMenu.onLink ) { + details.tagName = 'a'; + details.linkUrl = gContextMenu.linkURL; + } + + callback(details, { + id: tabWatcher.tabIdFromTarget(gContextMenu.browser), + url: gContextMenu.browser.currentURI.asciiSpec + }); + }; + + for ( var win of winWatcher.getWindows() ) { + this.register(win.document); + } +}; + +/******************************************************************************/ + +vAPI.contextMenu.remove = function() { + for ( var win of winWatcher.getWindows() ) { + this.unregister(win.document); + } + + this.menuItemId = null; + this.menuLabel = null; + this.contexts = null; + this.onCommand = null; +}; + +/******************************************************************************/ +/******************************************************************************/ + +var optionsObserver = (function() { + var addonId = 'uMatrix@raymondhill.net'; + + var commandHandler = function() { + switch ( this.id ) { + case 'showDashboardButton': + vAPI.tabs.open({ url: 'dashboard.html', index: -1 }); + break; + case 'showLoggerButton': + vAPI.tabs.open({ url: 'logger-ui.html', index: -1 }); + break; + default: + break; + } + }; + + var setupOptionsButton = function(doc, id) { + var button = doc.getElementById(id); + if ( button === null ) { + return; + } + button.addEventListener('command', commandHandler); + button.label = vAPI.i18n(id); + }; + + var setupOptionsButtons = function(doc) { + setupOptionsButton(doc, 'showDashboardButton'); + setupOptionsButton(doc, 'showLoggerButton'); + }; + + var observer = { + observe: function(doc, topic, id) { + if ( id !== addonId ) { + return; + } + + setupOptionsButtons(doc); + } + }; + + // https://github.com/gorhill/uBlock/issues/948 + // Older versions of Firefox can throw here when looking up `currentURI`. + + var canInit = function() { + try { + var tabBrowser = tabWatcher.currentBrowser(); + return tabBrowser && + tabBrowser.currentURI && + tabBrowser.currentURI.spec === 'about:addons' && + tabBrowser.contentDocument && + tabBrowser.contentDocument.readyState === 'complete'; + } catch (ex) { + } + }; + + // Manually add the buttons if the `about:addons` page is already opened. + + var init = function() { + if ( canInit() ) { + setupOptionsButtons(tabWatcher.currentBrowser().contentDocument); + } + }; + + var unregister = function() { + Services.obs.removeObserver(observer, 'addon-options-displayed'); + }; + + var register = function() { + Services.obs.addObserver(observer, 'addon-options-displayed', false); + cleanupTasks.push(unregister); + deferUntil(canInit, init, { next: 463 }); + }; + + return { + register: register, + unregister: unregister + }; +})(); + +optionsObserver.register(); + +/******************************************************************************/ +/******************************************************************************/ + +vAPI.lastError = function() { + return null; +}; + +/******************************************************************************/ +/******************************************************************************/ + +// This is called only once, when everything has been loaded in memory after +// the extension was launched. It can be used to inject content scripts +// in already opened web pages, to remove whatever nuisance could make it to +// the web pages before uBlock was ready. + +vAPI.onLoadAllCompleted = function() { + for ( var browser of tabWatcher.browsers() ) { + browser.messageManager.sendAsyncMessage( + location.host + '-load-completed' + ); + } +}; + +/******************************************************************************/ +/******************************************************************************/ + +// Likelihood is that we do not have to punycode: given punycode overhead, +// it's faster to check and skip than do it unconditionally all the time. + +var punycodeHostname = punycode.toASCII; +var isNotASCII = /[^\x21-\x7F]/; + +vAPI.punycodeHostname = function(hostname) { + return isNotASCII.test(hostname) ? punycodeHostname(hostname) : hostname; +}; + +vAPI.punycodeURL = function(url) { + if ( isNotASCII.test(url) ) { + return Services.io.newURI(url, null, null).asciiSpec; + } + return url; +}; + +/******************************************************************************/ +/******************************************************************************/ + +vAPI.cloud = (function() { + var extensionBranchPath = 'extensions.' + location.host; + var cloudBranchPath = extensionBranchPath + '.cloudStorage'; + + // https://github.com/gorhill/uBlock/issues/80#issuecomment-132081658 + // We must use get/setComplexValue in order to properly handle strings + // with unicode characters. + var iss = Ci.nsISupportsString; + var argstr = Components.classes['@mozilla.org/supports-string;1'] + .createInstance(iss); + + var options = { + defaultDeviceName: '', + deviceName: '' + }; + + // User-supplied device name. + try { + options.deviceName = Services.prefs + .getBranch(extensionBranchPath + '.') + .getComplexValue('deviceName', iss) + .data; + } catch(ex) { + } + + var getDefaultDeviceName = function() { + var name = ''; + try { + name = Services.prefs + .getBranch('services.sync.client.') + .getComplexValue('name', iss) + .data; + } catch(ex) { + } + + return name || window.navigator.platform || window.navigator.oscpu; + }; + + var start = function(dataKeys) { + var extensionBranch = Services.prefs.getBranch(extensionBranchPath + '.'); + var syncBranch = Services.prefs.getBranch('services.sync.prefs.sync.'); + + // Mark config entries as syncable + argstr.data = ''; + var dataKey; + for ( var i = 0; i < dataKeys.length; i++ ) { + dataKey = dataKeys[i]; + if ( extensionBranch.prefHasUserValue('cloudStorage.' + dataKey) === false ) { + extensionBranch.setComplexValue('cloudStorage.' + dataKey, iss, argstr); + } + syncBranch.setBoolPref(cloudBranchPath + '.' + dataKey, true); + } + }; + + var push = function(datakey, data, callback) { + var branch = Services.prefs.getBranch(cloudBranchPath + '.'); + var bin = { + 'source': options.deviceName || getDefaultDeviceName(), + 'tstamp': Date.now(), + 'data': data, + 'size': 0 + }; + bin.size = JSON.stringify(bin).length; + argstr.data = JSON.stringify(bin); + branch.setComplexValue(datakey, iss, argstr); + if ( typeof callback === 'function' ) { + callback(); + } + }; + + var pull = function(datakey, callback) { + var result = null; + var branch = Services.prefs.getBranch(cloudBranchPath + '.'); + try { + var json = branch.getComplexValue(datakey, iss).data; + if ( typeof json === 'string' ) { + result = JSON.parse(json); + } + } catch(ex) { + } + callback(result); + }; + + var getOptions = function(callback) { + if ( typeof callback !== 'function' ) { + return; + } + options.defaultDeviceName = getDefaultDeviceName(); + callback(options); + }; + + var setOptions = function(details, callback) { + if ( typeof details !== 'object' || details === null ) { + return; + } + + var branch = Services.prefs.getBranch(extensionBranchPath + '.'); + + if ( typeof details.deviceName === 'string' ) { + argstr.data = details.deviceName; + branch.setComplexValue('deviceName', iss, argstr); + options.deviceName = details.deviceName; + } + + getOptions(callback); + }; + + return { + start: start, + push: push, + pull: pull, + getOptions: getOptions, + setOptions: setOptions + }; +})(); + +/******************************************************************************/ +/******************************************************************************/ + +vAPI.browserData = {}; + +/******************************************************************************/ + +// https://developer.mozilla.org/en-US/docs/HTTP_Cache + +vAPI.browserData.clearCache = function(callback) { + // PURGE_DISK_DATA_ONLY:1 + // PURGE_DISK_ALL:2 + // PURGE_EVERYTHING:3 + // However I verified that no argument does clear the cache data. + // There is no cache2 for older versions of Firefox. + if ( Services.cache2 ) { + Services.cache2.clear(); + } else if ( Services.cache ) { + Services.cache.evictEntries(Services.cache.STORE_ON_DISK); + } + if ( typeof callback === 'function' ) { + callback(); + } +}; + +/******************************************************************************/ + +vAPI.browserData.clearOrigin = function(/* domain */) { + // TODO +}; + +/******************************************************************************/ +/******************************************************************************/ + +// https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsICookieManager2 +// https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsICookie2 +// https://developer.mozilla.org/en-US/docs/Observer_Notifications#Cookies + +vAPI.cookies = {}; + +/******************************************************************************/ + +vAPI.cookies.CookieEntry = function(ffCookie) { + this.domain = ffCookie.host; + this.name = ffCookie.name; + this.path = ffCookie.path; + this.secure = ffCookie.isSecure === true; + this.session = ffCookie.expires === 0; + this.value = ffCookie.value; +}; + +/******************************************************************************/ + +vAPI.cookies.start = function() { + Services.obs.addObserver(this, 'cookie-changed', false); + Services.obs.addObserver(this, 'private-cookie-changed', false); + cleanupTasks.push(this.stop.bind(this)); +}; + +/******************************************************************************/ + +vAPI.cookies.stop = function() { + Services.obs.removeObserver(this, 'cookie-changed'); + Services.obs.removeObserver(this, 'private-cookie-changed'); +}; + +/******************************************************************************/ + +vAPI.cookies.observe = function(subject, topic, reason) { + //if ( topic !== 'cookie-changed' && topic !== 'private-cookie-changed' ) { + // return; + //} + // + if ( reason === 'cleared' && typeof this.onAllRemoved === 'function' ) { + this.onAllRemoved(); + return; + } + if ( subject === null ) { + return; + } + if ( subject instanceof Ci.nsICookie2 === false ) { + try { + subject = subject.QueryInterface(Ci.nsICookie2); + } catch (ex) { + return; + } + } + if ( reason === 'deleted' ) { + if ( typeof this.onRemoved === 'function' ) { + this.onRemoved(new this.CookieEntry(subject)); + } + return; + } + if ( typeof this.onChanged === 'function' ) { + this.onChanged(new this.CookieEntry(subject)); + } +}; + +/******************************************************************************/ + +// Meant and expected to be asynchronous. + +vAPI.cookies.getAll = function(callback) { + if ( typeof callback !== 'function' ) { + return; + } + var onAsync = function() { + var out = []; + var enumerator = Services.cookies.enumerator; + var ffcookie; + while ( enumerator.hasMoreElements() ) { + ffcookie = enumerator.getNext(); + if ( ffcookie instanceof Ci.nsICookie ) { + out.push(new this.CookieEntry(ffcookie)); + } + } + callback(out); + }; + vAPI.setTimeout(onAsync.bind(this), 0); +}; + +/******************************************************************************/ + +vAPI.cookies.remove = function(details, callback) { + var uri = Services.io.newURI(details.url, null, null); + var cookies = Services.cookies; + cookies.remove(uri.asciiHost, details.name, uri.path, false, {}); + cookies.remove( '.' + uri.asciiHost, details.name, uri.path, false, {}); + if ( typeof callback === 'function' ) { + callback({ + domain: uri.asciiHost, + name: details.name, + path: uri.path + }); + } +}; + +/******************************************************************************/ +/******************************************************************************/ + +})(); + +/******************************************************************************/ diff --git a/js/vapi-client.js b/js/vapi-client.js new file mode 100644 index 0000000..7f9521d --- /dev/null +++ b/js/vapi-client.js @@ -0,0 +1,226 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 The uMatrix/uBlock Origin authors + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* jshint esnext: true */ +/* global addMessageListener, removeMessageListener, sendAsyncMessage */ + +// For non background pages + +'use strict'; + +/******************************************************************************/ + +(function(self) { + +/******************************************************************************/ + +// https://bugs.chromium.org/p/project-zero/issues/detail?id=1225&desc=6#c10 +if ( self.vAPI === undefined || self.vAPI.uMatrix !== true ) { + self.vAPI = { uMatrix: true }; +} + +var vAPI = self.vAPI; +vAPI.firefox = true; +vAPI.sessionId = String.fromCharCode(Date.now() % 25 + 97) + + Math.random().toString(36).slice(2); + +/******************************************************************************/ + +vAPI.setTimeout = vAPI.setTimeout || function(callback, delay) { + return setTimeout(function() { callback(); }, delay); +}; + +/******************************************************************************/ + +vAPI.shutdown = (function() { + var jobs = []; + + var add = function(job) { + jobs.push(job); + }; + + var exec = function() { + //console.debug('Shutting down...'); + var job; + while ( (job = jobs.pop()) ) { + job(); + } + }; + + return { + add: add, + exec: exec + }; +})(); + +/******************************************************************************/ + +vAPI.messaging = { + listeners: new Set(), + pending: new Map(), + requestId: 1, + connected: false, + + setup: function() { + this.addListener(this.builtinListener); + if ( this.toggleListenerCallback === null ) { + this.toggleListenerCallback = this.toggleListener.bind(this); + } + window.addEventListener('pagehide', this.toggleListenerCallback, true); + window.addEventListener('pageshow', this.toggleListenerCallback, true); + }, + + shutdown: function() { + if ( this.toggleListenerCallback !== null ) { + window.removeEventListener('pagehide', this.toggleListenerCallback, true); + window.removeEventListener('pageshow', this.toggleListenerCallback, true); + } + this.removeAllListeners(); + //service pending callbacks + var pending = this.pending; + this.pending.clear(); + for ( var callback of pending.values() ) { + if ( typeof callback === 'function' ) { + callback(null); + } + } + }, + + connect: function() { + if ( !this.connected ) { + if ( this.messageListenerCallback === null ) { + this.messageListenerCallback = this.messageListener.bind(this); + } + addMessageListener(this.messageListenerCallback); + this.connected = true; + } + }, + + disconnect: function() { + if ( this.connected ) { + removeMessageListener(); + this.connected = false; + } + }, + + messageListener: function(msg) { + var details = JSON.parse(msg); + if ( !details ) { + return; + } + + if ( details.broadcast ) { + this.sendToListeners(details.msg); + return; + } + + if ( details.requestId ) { + var listener = this.pending.get(details.requestId); + if ( listener !== undefined ) { + this.pending.delete(details.requestId); + listener(details.msg); + return; + } + } + }, + messageListenerCallback: null, + + builtinListener: function(msg) { + if ( typeof msg.cmd === 'string' && msg.cmd === 'injectScript' ) { + var details = msg.details; + if ( !details.allFrames && window !== window.top ) { + return; + } + self.injectScript(details.file); + } + }, + + send: function(channelName, message, callback) { + this.connect() + + message = { + channelName: self._sandboxId_ + '|' + channelName, + msg: message + }; + + if ( callback ) { + message.requestId = this.requestId++; + this.pending.set(message.requestId, callback); + } + + sendAsyncMessage('umatrix:background', message); + }, + + toggleListener: function({type, persisted}) { + if ( type === 'pagehide' && !persisted ) { + vAPI.shutdown.exec(); + this.shutdown(); + return; + } + + if ( type === 'pagehide' ) { + this.disconnect(); + } else /* if ( type === 'pageshow' ) */ { + this.connect(); + } + }, + toggleListenerCallback: null, + + sendToListeners: function(msg) { + for ( var listener of this.listeners ) { + listener(msg); + } + }, + + addListener: function(listener) { + this.listeners.add(listener); + this.connect() + }, + + removeListener: function(listener) { + this.listeners.delete(listener); + }, + + removeAllListeners: function() { + this.disconnect(); + this.listeners.clear();; + } +}; + +vAPI.messaging.setup() + +/******************************************************************************/ + +// No need to have vAPI client linger around after shutdown if +// we are not a top window (because element picker can still +// be injected in top window). +if ( window !== window.top ) { + vAPI.shutdown.add(function() { + vAPI = null; + }); +} + +/******************************************************************************/ + +})(this); + +/******************************************************************************/ diff --git a/js/vapi-common.js b/js/vapi-common.js new file mode 100644 index 0000000..3b51d17 --- /dev/null +++ b/js/vapi-common.js @@ -0,0 +1,192 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 The uMatrix/uBlock Origin authors + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global sendAsyncMessage */ + +// For background page or non-background pages + +'use strict'; + +/******************************************************************************/ + +(function(self) { + +/******************************************************************************/ + +const {Services} = Components.utils.import( + 'resource://gre/modules/Services.jsm', + null +); + +// https://bugs.chromium.org/p/project-zero/issues/detail?id=1225&desc=6#c10 +if ( self.vAPI === undefined || self.vAPI.uMatrix !== true ) { + self.vAPI = { uMatrix: true }; +} + +var vAPI = self.vAPI; + +/******************************************************************************/ + +vAPI.setTimeout = vAPI.setTimeout || function(callback, delay, extra) { + return setTimeout(function(a) { callback(a); }, delay, extra); +}; + +/******************************************************************************/ + +// http://www.w3.org/International/questions/qa-scripts#directions + +var setScriptDirection = function(language) { + document.body.setAttribute( + 'dir', + ['ar', 'he', 'fa', 'ps', 'ur'].indexOf(language) !== -1 ? 'rtl' : 'ltr' + ); +}; + +/******************************************************************************/ + +vAPI.download = function(details) { + if ( !details.url ) { + return; + } + + var a = document.createElement('a'); + a.href = details.url; + a.setAttribute('download', details.filename || ''); + a.dispatchEvent(new MouseEvent('click')); +}; + +/******************************************************************************/ + +vAPI.insertHTML = (function() { + const parser = Components.classes['@mozilla.org/parserutils;1'] + .getService(Components.interfaces.nsIParserUtils); + + // https://github.com/gorhill/uBlock/issues/845 + // Apparently dashboard pages execute with `about:blank` principal. + + return function(node, html) { + while ( node.firstChild ) { + node.removeChild(node.firstChild); + } + + node.appendChild(parser.parseFragment( + html, + parser.SanitizerAllowStyle, + false, + Services.io.newURI('about:blank', null, null), + document.documentElement + )); + }; +})(); + +/******************************************************************************/ + +vAPI.getURL = function(path) { + return 'chrome://' + location.host + '/content/' + path.replace(/^\/+/, ''); +}; + +/******************************************************************************/ + +vAPI.i18n = (function() { + var stringBundle = Services.strings.createBundle( + 'chrome://' + location.host + '/locale/messages.properties' + ); + + return function(s) { + try { + return stringBundle.GetStringFromName(s); + } catch (ex) { + return ''; + } + }; +})(); + +setScriptDirection(navigator.language); + +/******************************************************************************/ + +vAPI.closePopup = function() { + sendAsyncMessage(location.host + ':closePopup'); +}; + +/******************************************************************************/ + +// A localStorage-like object which should be accessible from the +// background page or auxiliary pages. +// This storage is optional, but it is nice to have, for a more polished user +// experience. + +vAPI.localStorage = { + pbName: '', + pb: null, + str: Components.classes['@mozilla.org/supports-string;1'] + .createInstance(Components.interfaces.nsISupportsString), + init: function(pbName) { + this.pbName = pbName; + this.pb = Services.prefs.getBranch(pbName); + }, + getItem: function(key) { + try { + return this.pb.getComplexValue( + key, + Components.interfaces.nsISupportsString + ).data; + } catch (ex) { + return null; + } + }, + setItem: function(key, value) { + this.str.data = value; + this.pb.setComplexValue( + key, + Components.interfaces.nsISupportsString, + this.str + ); + }, + getBool: function(key) { + try { + return this.pb.getBoolPref(key); + } catch (ex) { + return null; + } + }, + setBool: function(key, value) { + this.pb.setBoolPref(key, value); + }, + setDefaultBool: function(key, defaultValue) { + Services.prefs.getDefaultBranch(this.pbName).setBoolPref(key, defaultValue); + }, + removeItem: function(key) { + this.pb.clearUserPref(key); + }, + clear: function() { + this.pb.deleteBranch(''); + } +}; + +vAPI.localStorage.init('extensions.' + location.host + '.'); + +/******************************************************************************/ + +})(this); + +/******************************************************************************/ diff --git a/js/vapi-popup.js b/js/vapi-popup.js new file mode 100644 index 0000000..c1fbdbb --- /dev/null +++ b/js/vapi-popup.js @@ -0,0 +1,23 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 The uMatrix/uBlock Origin authors + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* Firefox: no platform-specific code */ diff --git a/js/xal.js b/js/xal.js new file mode 100644 index 0000000..9dba5b9 --- /dev/null +++ b/js/xal.js @@ -0,0 +1,72 @@ +/******************************************************************************* + + ηMatrix - a browser extension to black/white list requests. + Copyright (C) 2014-2019 Raymond Hill + Copyright (C) 2019 Alessio Vanni + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see {http://www.gnu.org/licenses/}. + + uMatrix Home: https://github.com/gorhill/uMatrix +*/ + +/* global chrome, µMatrix */ + +/******************************************************************************/ + +µMatrix.XAL = (function(){ + +/******************************************************************************/ + +var exports = {}; +var noopFunc = function(){}; + +/******************************************************************************/ + +exports.keyvalSetOne = function(key, val, callback) { + var bin = {}; + bin[key] = val; + vAPI.storage.set(bin, callback || noopFunc); +}; + +/******************************************************************************/ + +exports.keyvalGetOne = function(key, callback) { + vAPI.storage.get(key, callback); +}; + +/******************************************************************************/ + +exports.keyvalSetMany = function(dict, callback) { + vAPI.storage.set(dict, callback || noopFunc); +}; + +/******************************************************************************/ + +exports.keyvalRemoveOne = function(key, callback) { + vAPI.storage.remove(key, callback || noopFunc); +}; + +/******************************************************************************/ + +exports.keyvalRemoveAll = function(callback) { + vAPI.storage.clear(callback || noopFunc); +}; + +/******************************************************************************/ + +return exports; + +/******************************************************************************/ + +})(); |