From 12440ffb4b0556e1a986446b78a0cbf77b442cf3 Mon Sep 17 00:00:00 2001
From: Marcin Rataj <lidel@lidel.org>
Date: Tue, 10 Dec 2019 18:17:00 +0100
Subject: [PATCH 1/3] feat: disable integrations per website

This change replaces per-site redirect toggle with one that disables ALL
IPFS integrations: redirect, content scripts, and access to API via window.ipfs

It also changes the order of menu items to prioritize actions
related to a current tab.
---
 add-on/_locales/en/messages.json              | 26 +++++-----
 add-on/src/lib/ipfs-companion.js              |  9 ++--
 add-on/src/lib/ipfs-path.js                   |  5 ++
 add-on/src/lib/ipfs-proxy/enable-command.js   |  5 +-
 add-on/src/lib/ipfs-proxy/pre-acl.js          |  7 +--
 add-on/src/lib/ipfs-request.js                |  4 +-
 add-on/src/lib/options.js                     |  2 +-
 add-on/src/lib/state.js                       | 14 +++++-
 add-on/src/options/forms/gateways-form.js     | 16 +++----
 add-on/src/options/page.js                    |  2 +-
 .../popup/browser-action/context-actions.js   | 22 ++++-----
 add-on/src/popup/browser-action/operations.js |  2 +-
 add-on/src/popup/browser-action/page.js       |  6 +--
 add-on/src/popup/browser-action/store.js      | 21 +++++----
 add-on/src/popup/browser-action/tools.js      |  2 +-
 add-on/src/popup/page-action/page.js          |  4 +-
 .../lib/ipfs-proxy/enable-command.test.js     | 47 +++++++++++++++----
 .../functional/lib/ipfs-proxy/pre-acl.test.js | 38 ++++++++++++---
 test/functional/lib/state.test.js             | 42 +++++++++++++++++
 19 files changed, 194 insertions(+), 80 deletions(-)
 create mode 100644 test/functional/lib/state.test.js

diff --git a/add-on/_locales/en/messages.json b/add-on/_locales/en/messages.json
index a923b424a..d65ab0eb9 100644
--- a/add-on/_locales/en/messages.json
+++ b/add-on/_locales/en/messages.json
@@ -63,13 +63,13 @@
     "message": "Active Tab",
     "description": "A menu item in Browser Action pop-up (panel_activeTabSiteRedirectEnable)"
   },
-  "panel_activeTabSiteRedirectToggle": {
-    "message": "Redirect on $1",
-    "description": "A menu item in Browser Action pop-up (panel_activeTabSiteRedirectToggle)"
+  "panel_activeTabSiteIntegrationsToggle": {
+    "message": "Enable on $1",
+    "description": "A menu item in Browser Action pop-up (panel_activeTabSiteIntegrationsToggle)"
   },
-  "panel_activeTabSiteRedirectToggleTooltip": {
-    "message": "Click to toggle gateway redirects on $1",
-    "description": "A menu item tooltip in Browser Action pop-up (panel_activeTabSiteRedirectToggleTooltip)"
+  "panel_activeTabSiteIntegrationsToggleTooltip": {
+    "message": "Click to toggle all IPFS integrations on $1",
+    "description": "A menu item tooltip in Browser Action pop-up (panel_activeTabSiteIntegrationsToggleTooltip)"
   },
   "panel_pinCurrentIpfsAddress": {
     "message": "Pin IPFS Resource",
@@ -280,7 +280,7 @@
     "description": "An option description on the Preferences screen (option_useCustomGateway_description)"
   },
   "option_dnslinkRedirect_title": {
-    "message": "Force page load from custom gateway",
+    "message": "Load websites from Custom Gateway",
     "description": "An option title on the Preferences screen (option_dnslinkRedirect_title)"
   },
   "option_dnslinkRedirect_description": {
@@ -296,15 +296,15 @@
     "description": "An option description on the Preferences screen (option_dnslinkDataPreload_description)"
   },
   "option_dnslinkRedirect_warning": {
-    "message": "Redirecting to a path-based gateway breaks Origin-based security isolation of DNSLink website! Please leave this disabled unless you are aware of (and ok with) related risks.",
+    "message": "Redirecting to a path-based gateway breaks Origin-based security isolation of DNSLink websites. Make sure you understand related risks.",
     "description": "A warning on the Preferences screen, displayed when URL does not belong to Secure Context (option_customGatewayUrl_warning)"
   },
-  "option_noRedirectHostnames_title": {
-    "message": "Redirect Opt-Outs",
-    "description": "An option title on the Preferences screen (option_noRedirectHostnames_title)"
+  "option_noIntegrationsHostnames_title": {
+    "message": "IPFS Integrations Opt-Outs",
+    "description": "An option title on the Preferences screen (option_noIntegrationsHostnames_title)"
   },
-  "option_noRedirectHostnames_description": {
-    "message": "List of websites that should not be redirected to the Custom Gateway (includes subresources from other domains). One hostname per line.",
+  "option_noIntegrationsHostnames_description": {
+    "message": "List of websites that should not have any IPFS integrations enabled. One hostname per line.",
     "description": "An option description on the Preferences screen (option_noRedirectHostnames_description)"
   },
   "option_publicGatewayUrl_title": {
diff --git a/add-on/src/lib/ipfs-companion.js b/add-on/src/lib/ipfs-companion.js
index 4c49cccce..63aa50be5 100644
--- a/add-on/src/lib/ipfs-companion.js
+++ b/add-on/src/lib/ipfs-companion.js
@@ -241,7 +241,7 @@ module.exports = async function init () {
       openViaWebUI: state.openViaWebUI,
       apiURLString: dropSlash(state.apiURLString),
       redirect: state.redirect,
-      noRedirectHostnames: state.noRedirectHostnames,
+      noIntegrationsHostnames: state.noIntegrationsHostnames,
       currentTab: await browser.tabs.query({ active: true, currentWindow: true }).then(tabs => tabs[0])
     }
     try {
@@ -257,7 +257,7 @@ module.exports = async function init () {
       info.isIpfsContext = ipfsPathValidator.isIpfsPageActionsContext(url)
       info.currentDnslinkFqdn = dnslinkResolver.findDNSLinkHostname(url)
       info.currentFqdn = info.currentDnslinkFqdn || new URL(url).hostname
-      info.currentTabRedirectOptOut = info.noRedirectHostnames && info.noRedirectHostnames.includes(info.currentFqdn)
+      info.currentTabIntegrationsOptOut = info.noIntegrationsHostnames && info.noIntegrationsHostnames.includes(info.currentFqdn)
       info.isRedirectContext = info.currentFqdn && ipfsPathValidator.isRedirectPageActionsContext(url)
     }
     // Still here?
@@ -365,7 +365,8 @@ module.exports = async function init () {
 
   async function onDOMContentLoaded (details) {
     if (!state.active) return // skip content script injection when off
-    if (!details.url.startsWith('http')) return // skip special pages
+    if (!details.url || !details.url.startsWith('http')) return // skip empty and special pages
+    if (!state.activeIntegrations(details.url)) return // skip if opt-out exists
     // console.info(`[ipfs-companion] onDOMContentLoaded`, details)
     if (state.linkify) {
       console.info(`[ipfs-companion] Running linkfy experiment for ${details.url}`)
@@ -679,7 +680,7 @@ module.exports = async function init () {
         case 'detectIpfsPathHeader':
         case 'preloadAtPublicGateway':
         case 'openViaWebUI':
-        case 'noRedirectHostnames':
+        case 'noIntegrationsHostnames':
         case 'dnslinkRedirect':
           state[key] = change.newValue
           break
diff --git a/add-on/src/lib/ipfs-path.js b/add-on/src/lib/ipfs-path.js
index 7aa623758..1b95a6362 100644
--- a/add-on/src/lib/ipfs-path.js
+++ b/add-on/src/lib/ipfs-path.js
@@ -254,6 +254,11 @@ function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) {
 
       const directCid = IsIpfs.ipfsPath(result) ? result.split('/')[2] : result
       return directCid
+    },
+
+    // Returns true when opt-out for provided URL exists
+    activeIntegrations (url) {
+      return getState().activeIntegrations(url)
     }
   }
 
diff --git a/add-on/src/lib/ipfs-proxy/enable-command.js b/add-on/src/lib/ipfs-proxy/enable-command.js
index 23d7a4605..ff87c0074 100644
--- a/add-on/src/lib/ipfs-proxy/enable-command.js
+++ b/add-on/src/lib/ipfs-proxy/enable-command.js
@@ -10,10 +10,11 @@ const { createProxyAclError } = require('./pre-acl')
 function createEnableCommand (getIpfs, getState, getScope, accessControl, requestAccess) {
   return async (opts) => {
     const scope = await getScope()
+    const state = getState()
     log(`received window.ipfs.enable request from ${scope}`, opts)
 
-    // Check if all access to the IPFS node is disabled
-    if (!getState().ipfsProxy) throw new Error('User disabled access to API proxy in IPFS Companion')
+    // Check if access to the IPFS node is disabled
+    if (!state.ipfsProxy || !state.activeIntegrations(scope)) throw new Error('User disabled access to API proxy in IPFS Companion')
 
     // NOOP if .enable() was called without any arguments
     if (!opts) return
diff --git a/add-on/src/lib/ipfs-proxy/pre-acl.js b/add-on/src/lib/ipfs-proxy/pre-acl.js
index da9bac61b..d99d14628 100644
--- a/add-on/src/lib/ipfs-proxy/pre-acl.js
+++ b/add-on/src/lib/ipfs-proxy/pre-acl.js
@@ -3,12 +3,13 @@
 // no access decision has been made yet.
 function createPreAcl (permission, getState, getScope, accessControl, requestAccess) {
   return async (...args) => {
-    // Check if all access to the IPFS node is disabled
-    if (!getState().ipfsProxy) {
+    const scope = await getScope()
+    const state = getState()
+    // Check if access to the IPFS node is disabled
+    if (!state.ipfsProxy || !state.activeIntegrations(scope)) {
       throw createProxyAclError(undefined, undefined, 'User disabled access to API proxy in IPFS Companion')
     }
 
-    const scope = await getScope()
     const access = await getAccessWithPrompt(accessControl, requestAccess, scope, permission)
 
     if (!access.allow) {
diff --git a/add-on/src/lib/ipfs-request.js b/add-on/src/lib/ipfs-request.js
index d0e870ef2..8901ad93b 100644
--- a/add-on/src/lib/ipfs-request.js
+++ b/add-on/src/lib/ipfs-request.js
@@ -76,11 +76,11 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
     if (request.url.startsWith('http://127.0.0.1') || request.url.startsWith('http://localhost') || request.url.startsWith('http://[::1]')) {
       ignore(request.requestId)
     }
-    // skip if a per-site redirect opt-out exists
+    // skip if a per-site opt-out exists
     const parentUrl = request.originUrl || request.initiator // FF: originUrl (Referer-like Origin URL), Chrome: initiator (just Origin)
     const fqdn = new URL(request.url).hostname
     const parentFqdn = parentUrl && parentUrl !== 'null' && request.url !== parentUrl ? new URL(parentUrl).hostname : null
-    if (state.noRedirectHostnames.some(optout =>
+    if (state.noIntegrationsHostnames.some(optout =>
       fqdn !== 'gateway.ipfs.io' && (fqdn.endsWith(optout) || (parentFqdn && parentFqdn.endsWith(optout))
       ))) {
       ignore(request.requestId)
diff --git a/add-on/src/lib/options.js b/add-on/src/lib/options.js
index e747b5065..94ba7af43 100644
--- a/add-on/src/lib/options.js
+++ b/add-on/src/lib/options.js
@@ -13,7 +13,7 @@ exports.optionDefaults = Object.freeze({
   publicGatewayUrl: 'https://ipfs.io',
   publicSubdomainGatewayUrl: 'https://dweb.link',
   useCustomGateway: true,
-  noRedirectHostnames: [],
+  noIntegrationsHostnames: [],
   automaticMode: true,
   linkify: false,
   dnslinkPolicy: 'best-effort',
diff --git a/add-on/src/lib/state.js b/add-on/src/lib/state.js
index c422f17bd..59d703305 100644
--- a/add-on/src/lib/state.js
+++ b/add-on/src/lib/state.js
@@ -8,7 +8,7 @@ const offlinePeerCount = -1
 // which should work without setting CORS headers
 const webuiCid = 'Qmexhq2sBHnXQbvyP2GfUdbnY7HCagH2Mw5vUNSBn2nxip' // v2.7.2
 
-function initState (options) {
+function initState (options, overrides) {
   // we store options and some pregenerated values to avoid async storage
   // reads and minimize performance impact on overall browsing experience
   const state = Object.assign({}, options)
@@ -31,6 +31,18 @@ function initState (options) {
   state.dnslinkPolicy = String(options.dnslinkPolicy) === 'false' ? false : options.dnslinkPolicy
   state.webuiCid = webuiCid
   state.webuiRootUrl = `${state.gwURLString}ipfs/${state.webuiCid}/`
+  // attach helper functions
+  state.activeIntegrations = (url) => {
+    if (!state.active) return false
+    try {
+      const fqdn = new URL(url).hostname
+      return !(state.noIntegrationsHostnames.find(host => fqdn.endsWith(host)))
+    } catch (_) {
+      return false
+    }
+  }
+  // apply optional overrides
+  if (overrides) Object.assign(state, overrides)
   return state
 }
 
diff --git a/add-on/src/options/forms/gateways-form.js b/add-on/src/options/forms/gateways-form.js
index 09e9f3d9d..1d6133f94 100644
--- a/add-on/src/options/forms/gateways-form.js
+++ b/add-on/src/options/forms/gateways-form.js
@@ -14,7 +14,7 @@ function gatewaysForm ({
   ipfsNodeType,
   customGatewayUrl,
   useCustomGateway,
-  noRedirectHostnames,
+  noIntegrationsHostnames,
   publicGatewayUrl,
   publicSubdomainGatewayUrl,
   onOptionChange
@@ -23,7 +23,7 @@ function gatewaysForm ({
   const onUseCustomGatewayChange = onOptionChange('useCustomGateway')
   const onPublicGatewayUrlChange = onOptionChange('publicGatewayUrl', normalizeGatewayURL)
   const onPublicSubdomainGatewayUrlChange = onOptionChange('publicSubdomainGatewayUrl', normalizeGatewayURL)
-  const onNoRedirectHostnamesChange = onOptionChange('noRedirectHostnames', hostTextToArray)
+  const onNoIntegrationsHostnamesChange = onOptionChange('noIntegrationsHostnames', hostTextToArray)
   const mixedContentWarning = !secureContextUrl.test(customGatewayUrl)
   const supportRedirectToCustomGateway = ipfsNodeType !== 'embedded'
   const allowChangeOfCustomGateway = ipfsNodeType !== 'embedded:chromesockets'
@@ -110,18 +110,18 @@ function gatewaysForm ({
           ` : null}
           ${supportRedirectToCustomGateway ? html`
             <div>
-              <label for="noRedirectHostnames">
+              <label for="noIntegrationsHostnames">
                 <dl>
-                  <dt>${browser.i18n.getMessage('option_noRedirectHostnames_title')}</dt>
-                  <dd>${browser.i18n.getMessage('option_noRedirectHostnames_description')}</dd>
+                  <dt>${browser.i18n.getMessage('option_noIntegrationsHostnames_title')}</dt>
+                  <dd>${browser.i18n.getMessage('option_noIntegrationsHostnames_description')}</dd>
                 </dl>
               </label>
               <textarea
-                id="noRedirectHostnames"
+                id="noIntegrationsHostnames"
                 spellcheck="false"
-                onchange=${onNoRedirectHostnamesChange}
+                onchange=${onNoIntegrationsHostnamesChange}
                 rows="4"
-                >${hostArrayToText(noRedirectHostnames)}</textarea>
+                >${hostArrayToText(noIntegrationsHostnames)}</textarea>
             </div>
           ` : null}
       </fieldset>
diff --git a/add-on/src/options/page.js b/add-on/src/options/page.js
index a3965a1b0..c616327f9 100644
--- a/add-on/src/options/page.js
+++ b/add-on/src/options/page.js
@@ -69,7 +69,7 @@ module.exports = function optionsPage (state, emit) {
     useCustomGateway: state.options.useCustomGateway,
     publicGatewayUrl: state.options.publicGatewayUrl,
     publicSubdomainGatewayUrl: state.options.publicSubdomainGatewayUrl,
-    noRedirectHostnames: state.options.noRedirectHostnames,
+    noIntegrationsHostnames: state.options.noIntegrationsHostnames,
     onOptionChange
   })}
   ${fileImportForm({
diff --git a/add-on/src/popup/browser-action/context-actions.js b/add-on/src/popup/browser-action/context-actions.js
index f497a4574..3af22fdad 100644
--- a/add-on/src/popup/browser-action/context-actions.js
+++ b/add-on/src/popup/browser-action/context-actions.js
@@ -22,7 +22,7 @@ function contextActions ({
   currentTab,
   currentFqdn,
   currentDnslinkFqdn,
-  currentTabRedirectOptOut,
+  currentTabIntegrationsOptOut,
   ipfsNodeType,
   isIpfsContext,
   isPinning,
@@ -30,7 +30,7 @@ function contextActions ({
   isPinned,
   isIpfsOnline,
   isApiAvailable,
-  onToggleSiteRedirect,
+  onToggleSiteIntegrations,
   onViewOnGateway,
   onCopy,
   onPin,
@@ -69,24 +69,22 @@ function contextActions ({
   </div>
     `
   }
-  /* TODO: change "redirect on {fqdn}" to "disable on {fqdn}" and disable all integrations
-  // removed per site toggle for now: ${renderSiteRedirectToggle()}
-  const renderSiteRedirectToggle = () => {
+  const renderSiteIntegrationsToggle = () => {
     if (!isRedirectContext) return
     return html`
   ${navItem({
-    text: browser.i18n.getMessage('panel_activeTabSiteRedirectToggle', currentFqdn),
-    title: browser.i18n.getMessage('panel_activeTabSiteRedirectToggleTooltip', currentFqdn),
+    text: browser.i18n.getMessage('panel_activeTabSiteIntegrationsToggle', currentFqdn),
+    title: browser.i18n.getMessage('panel_activeTabSiteIntegrationsToggleTooltip', currentFqdn),
     style: 'truncate',
-    disabled: !(active && redirect),
-    switchValue: active && redirect && !currentTabRedirectOptOut,
-    onClick: onToggleSiteRedirect
+    disabled: !(active),
+    switchValue: active && !currentTabIntegrationsOptOut,
+    onClick: onToggleSiteIntegrations
   })}
       `
   }
-  */
   return html`
     <div class='fade-in pv1'>
+  ${renderSiteIntegrationsToggle()}
   ${renderIpfsContextItems()}
     </div>
   `
@@ -101,7 +99,7 @@ function activeTabActions (state) {
   return html`
       <div>
       ${navHeader('panel_activeTabSectionHeader')}
-      <div class="fade-in pv1 bb b--black-10">
+      <div class="fade-in pv0 bb b--black-10">
         ${contextActions(state)}
       </div>
       </div>
diff --git a/add-on/src/popup/browser-action/operations.js b/add-on/src/popup/browser-action/operations.js
index 9df83f0db..15761c034 100644
--- a/add-on/src/popup/browser-action/operations.js
+++ b/add-on/src/popup/browser-action/operations.js
@@ -13,7 +13,7 @@ module.exports = function operations ({
 }) {
   const activeRedirectSwitch = active && ipfsNodeType !== 'embedded'
   return html`
-    <div class="fade-in pv1 bb b--black-10">
+    <div class="fade-in pb1">
   ${navItem({
     text: browser.i18n.getMessage('panel_redirectToggle'),
     title: browser.i18n.getMessage('panel_redirectToggleTooltip'),
diff --git a/add-on/src/popup/browser-action/page.js b/add-on/src/popup/browser-action/page.js
index e17a10339..81524a710 100644
--- a/add-on/src/popup/browser-action/page.js
+++ b/add-on/src/popup/browser-action/page.js
@@ -20,19 +20,19 @@ module.exports = function browserActionPage (state, emit) {
   const onOpenWebUi = () => emit('openWebUi')
   const onOpenPrefs = () => emit('openPrefs')
   const onToggleGlobalRedirect = () => emit('toggleGlobalRedirect')
-  const onToggleSiteRedirect = () => emit('toggleSiteRedirect')
+  const onToggleSiteIntegrations = () => emit('toggleSiteIntegrations')
   const onToggleActive = () => emit('toggleActive')
 
   const headerProps = Object.assign({ onToggleActive, onOpenPrefs }, state)
-  const activeTabActionsProps = Object.assign({ onViewOnGateway, onToggleSiteRedirect, onCopy, onPin, onUnPin }, state)
+  const activeTabActionsProps = Object.assign({ onViewOnGateway, onToggleSiteIntegrations, onCopy, onPin, onUnPin }, state)
   const opsProps = Object.assign({ onQuickImport, onOpenWebUi, onToggleGlobalRedirect }, state)
 
   return html`
     <div class="sans-serif" style="text-rendering: optimizeLegibility;">
       ${header(headerProps)}
-      ${operations(opsProps)}
       ${activeTabActions(activeTabActionsProps)}
       ${tools(opsProps)}
+      ${operations(opsProps)}
     </div>
   `
 }
diff --git a/add-on/src/popup/browser-action/store.js b/add-on/src/popup/browser-action/store.js
index c6999cc71..e1ae791f6 100644
--- a/add-on/src/popup/browser-action/store.js
+++ b/add-on/src/popup/browser-action/store.js
@@ -32,7 +32,7 @@ module.exports = (state, emitter) => {
     currentTab: null,
     currentFqdn: null,
     currentDnslinkFqdn: null,
-    noRedirectHostnames: []
+    noIntegrationsHostnames: []
   })
 
   let port
@@ -166,22 +166,23 @@ module.exports = (state, emitter) => {
     }
   })
 
-  emitter.on('toggleSiteRedirect', async () => {
-    state.currentTabRedirectOptOut = !state.currentTabRedirectOptOut
+  emitter.on('toggleSiteIntegrations', async () => {
+    state.currentTabIntegrationsOptOut = !state.currentTabIntegrationsOptOut
     emitter.emit('render')
 
     try {
-      let noRedirectHostnames = state.noRedirectHostnames
+      let noIntegrationsHostnames = state.noIntegrationsHostnames
       // if we are on /ipns/fqdn.tld/ then use hostname from DNSLink
       const fqdn = state.currentDnslinkFqdn || state.currentFqdn
-      if (noRedirectHostnames.includes(fqdn)) {
-        noRedirectHostnames = noRedirectHostnames.filter(host => !host.endsWith(fqdn))
+      if (noIntegrationsHostnames.includes(fqdn)) {
+        noIntegrationsHostnames = noIntegrationsHostnames.filter(host => !host.endsWith(fqdn))
       } else {
-        noRedirectHostnames.push(fqdn)
+        noIntegrationsHostnames.push(fqdn)
       }
-      // console.dir('toggleSiteRedirect', state)
-      await browser.storage.local.set({ noRedirectHostnames })
+      // console.dir('toggleSiteIntegrations', state)
+      await browser.storage.local.set({ noIntegrationsHostnames })
 
+      // TODO: remove below? does it still make sense in "integrations toggle" context?
       // Reload the current tab to apply updated redirect preference
       if (!state.currentDnslinkFqdn || !IsIpfs.ipnsUrl(state.currentTab.url)) {
         // No DNSLink, reload URL as-is
@@ -198,7 +199,7 @@ module.exports = (state, emitter) => {
         })
       }
     } catch (error) {
-      console.error(`Unable to update redirect state due to ${error}`)
+      console.error(`Unable to update integrations state due to ${error}`)
       emitter.emit('render')
     }
   })
diff --git a/add-on/src/popup/browser-action/tools.js b/add-on/src/popup/browser-action/tools.js
index 03d2ccd0e..2322a958e 100644
--- a/add-on/src/popup/browser-action/tools.js
+++ b/add-on/src/popup/browser-action/tools.js
@@ -20,7 +20,7 @@ module.exports = function tools ({
   return html`
     <div>
     ${navHeader('panel_toolsSectionHeader')}
-    <div class="fade-in pv1 bb b--black-10">
+    <div class="fade-in pt1">
   ${navItem({
     text: browser.i18n.getMessage('panel_quickImport'),
     style: 'b',
diff --git a/add-on/src/popup/page-action/page.js b/add-on/src/popup/page-action/page.js
index 1b672e143..4c668dae2 100644
--- a/add-on/src/popup/page-action/page.js
+++ b/add-on/src/popup/page-action/page.js
@@ -13,9 +13,9 @@ module.exports = function pageActionPage (state, emit) {
   const onCopy = (copyAction) => emit('copy', copyAction)
   const onPin = () => emit('pin')
   const onUnPin = () => emit('unPin')
-  const onToggleSiteRedirect = () => emit('toggleSiteRedirect')
+  const onToggleSiteIntegrations = () => emit('toggleSiteIntegrations')
 
-  const contextActionsProps = Object.assign({ onViewOnGateway, onCopy, onPin, onUnPin, onToggleSiteRedirect }, state)
+  const contextActionsProps = Object.assign({ onViewOnGateway, onCopy, onPin, onUnPin, onToggleSiteIntegrations }, state)
 
   // Instant init: page-action is shown only in ipfsContext
   contextActionsProps.isIpfsContext = true
diff --git a/test/functional/lib/ipfs-proxy/enable-command.test.js b/test/functional/lib/ipfs-proxy/enable-command.test.js
index 9ac50241a..76137e336 100644
--- a/test/functional/lib/ipfs-proxy/enable-command.test.js
+++ b/test/functional/lib/ipfs-proxy/enable-command.test.js
@@ -10,6 +10,8 @@ const Sinon = require('sinon')
 const AccessControl = require('../../../../add-on/src/lib/ipfs-proxy/access-control')
 const createEnableCommand = require('../../../../add-on/src/lib/ipfs-proxy/enable-command')
 const createRequestAccess = require('../../../../add-on/src/lib/ipfs-proxy/request-access')
+const { initState } = require('../../../../add-on/src/lib/state')
+const { optionDefaults } = require('../../../../add-on/src/lib/options')
 
 describe('lib/ipfs-proxy/enable-command', () => {
   before(() => {
@@ -18,7 +20,32 @@ describe('lib/ipfs-proxy/enable-command', () => {
   })
 
   it('should throw if proxy access is disabled globally', async () => {
-    const getState = () => ({ ipfsProxy: false })
+    const getState = () => initState(optionDefaults, { ipfsProxy: false })
+    const accessControl = new AccessControl(new Storage())
+    const getScope = () => 'https://1.foo.tld/path/'
+    const getIpfs = () => {}
+    const requestAccess = createRequestAccess(browser, screen)
+    const enable = createEnableCommand(getIpfs, getState, getScope, accessControl, requestAccess)
+    const permissions = { commands: ['files.mkdir', 'id', 'version'] }
+
+    let error
+
+    try {
+      await enable(permissions)
+    } catch (err) {
+      error = err
+    }
+
+    expect(() => { if (error) throw error }).to.throw('User disabled access to API proxy in IPFS Companion')
+    expect(error.scope).to.equal(undefined)
+    expect(error.permissions).to.be.equal(undefined)
+  })
+
+  it('should throw if ALL IPFS integrations are disabled for requested scope', async () => {
+    const getState = () => initState(optionDefaults, {
+      ipfsProxy: true,
+      noIntegrationsHostnames: ['foo.tld']
+    })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://1.foo.tld/path/'
     const getIpfs = () => {}
@@ -40,7 +67,7 @@ describe('lib/ipfs-proxy/enable-command', () => {
   })
 
   it('should throw if access to unknown command is requested', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://2.foo.tld/path/'
     const getIpfs = () => {}
@@ -59,7 +86,7 @@ describe('lib/ipfs-proxy/enable-command', () => {
   })
 
   it('should return without prompt if called without any arguments', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://3.foo.tld/path/'
     const getIpfs = () => {}
@@ -73,7 +100,7 @@ describe('lib/ipfs-proxy/enable-command', () => {
   })
 
   it('should request access if no grant exists', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://4.foo.tld/path/'
     const getIpfs = () => {}
@@ -89,7 +116,7 @@ describe('lib/ipfs-proxy/enable-command', () => {
   })
 
   it('should request access if partial grant exists', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://4.foo.tld/path/'
     const getIpfs = () => {}
@@ -110,7 +137,7 @@ describe('lib/ipfs-proxy/enable-command', () => {
   })
 
   it('should deny access if any partial deny already exists', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://4.foo.tld/path/'
     const getIpfs = () => {}
@@ -141,7 +168,7 @@ describe('lib/ipfs-proxy/enable-command', () => {
   })
 
   it('should deny access when user denies request', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://5.foo.tld/path/'
     const getIpfs = () => {}
@@ -162,7 +189,7 @@ describe('lib/ipfs-proxy/enable-command', () => {
   })
 
   it('should not re-request if denied', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://6.foo.tld/path/'
     const getIpfs = () => {}
@@ -195,7 +222,7 @@ describe('lib/ipfs-proxy/enable-command', () => {
   })
 
   it('should have a well-formed Error if denied', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://7.foo.tld/path/'
     const getIpfs = () => {}
@@ -222,7 +249,7 @@ describe('lib/ipfs-proxy/enable-command', () => {
   })
 
   it('should not re-request if allowed', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://8.foo.tld/path/'
     const getIpfs = () => {}
diff --git a/test/functional/lib/ipfs-proxy/pre-acl.test.js b/test/functional/lib/ipfs-proxy/pre-acl.test.js
index 658cb6e7b..c9134149f 100644
--- a/test/functional/lib/ipfs-proxy/pre-acl.test.js
+++ b/test/functional/lib/ipfs-proxy/pre-acl.test.js
@@ -6,6 +6,8 @@ const Storage = require('mem-storage-area/Storage')
 const Sinon = require('sinon')
 const AccessControl = require('../../../../add-on/src/lib/ipfs-proxy/access-control')
 const { createPreAcl } = require('../../../../add-on/src/lib/ipfs-proxy/pre-acl')
+const { initState } = require('../../../../add-on/src/lib/state')
+const { optionDefaults } = require('../../../../add-on/src/lib/options')
 
 describe('lib/ipfs-proxy/pre-acl', () => {
   before(() => {
@@ -13,7 +15,7 @@ describe('lib/ipfs-proxy/pre-acl', () => {
   })
 
   it('should throw if access is disabled', async () => {
-    const getState = () => ({ ipfsProxy: false })
+    const getState = () => initState(optionDefaults, { ipfsProxy: false })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://ipfs.io/'
     const permission = 'files.add'
@@ -33,8 +35,32 @@ describe('lib/ipfs-proxy/pre-acl', () => {
     expect(error.permissions).to.be.equal(undefined)
   })
 
+  it('should throw if ALL IPFS integrations are disabled for requested scope', async () => {
+    const getState = () => initState(optionDefaults, {
+      ipfsProxy: true,
+      noIntegrationsHostnames: ['foo.tld']
+    })
+    const accessControl = new AccessControl(new Storage())
+    const getScope = () => 'https://2.foo.tld/bar/buzz/'
+    const permission = 'files.add'
+
+    const preAcl = createPreAcl(permission, getState, getScope, accessControl)
+
+    let error
+
+    try {
+      await preAcl()
+    } catch (err) {
+      error = err
+    }
+
+    expect(() => { if (error) throw error }).to.throw('User disabled access to API proxy in IPFS Companion')
+    expect(error.scope).to.equal(undefined)
+    expect(error.permissions).to.be.equal(undefined)
+  })
+
   it('should request access if no grant exists', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://ipfs.io/'
     const permission = 'files.add'
@@ -47,7 +73,7 @@ describe('lib/ipfs-proxy/pre-acl', () => {
   })
 
   it('should deny access when user denies request', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://ipfs.io/'
     const permission = 'files.add'
@@ -67,7 +93,7 @@ describe('lib/ipfs-proxy/pre-acl', () => {
   })
 
   it('should not re-request if denied', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://ipfs.io/'
     const permission = 'files.add'
@@ -99,7 +125,7 @@ describe('lib/ipfs-proxy/pre-acl', () => {
   })
 
   it('should have a well-formed Error if denied', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://ipfs.io/'
     const permission = 'files.add'
@@ -124,7 +150,7 @@ describe('lib/ipfs-proxy/pre-acl', () => {
   })
 
   it('should not re-request if allowed', async () => {
-    const getState = () => ({ ipfsProxy: true })
+    const getState = () => initState(optionDefaults, { ipfsProxy: true })
     const accessControl = new AccessControl(new Storage())
     const getScope = () => 'https://ipfs.io/'
     const permission = 'files.add'
diff --git a/test/functional/lib/state.test.js b/test/functional/lib/state.test.js
new file mode 100644
index 000000000..dea2b55e0
--- /dev/null
+++ b/test/functional/lib/state.test.js
@@ -0,0 +1,42 @@
+'use strict'
+const { describe, it, beforeEach } = require('mocha')
+const { expect } = require('chai')
+const { URL } = require('url')
+const { initState } = require('../../../add-on/src/lib/state')
+const { optionDefaults } = require('../../../add-on/src/lib/options')
+
+describe('state.js', function () {
+  let state
+
+  beforeEach(function () {
+    global.URL = URL
+    state = Object.assign(initState(optionDefaults), { peerCount: 1 })
+  })
+
+  describe('activeIntegrations(url)', function () {
+    it('should return false if input is undefined', async function () {
+      expect(state.activeIntegrations(undefined)).to.equal(false)
+    })
+    it('should return true if host is not on the opt-out list', async function () {
+      state.noIntegrationsHostnames = ['pl.wikipedia.org']
+      const url = 'https://en.wikipedia.org/wiki/Main_Page'
+      expect(state.activeIntegrations(url)).to.equal(true)
+    })
+    it('should return false if host is not on the opt-out list but global toggle is off', async function () {
+      state.noIntegrationsHostnames = ['pl.wikipedia.org']
+      state.active = false
+      const url = 'https://en.wikipedia.org/wiki/Main_Page'
+      expect(state.activeIntegrations(url)).to.equal(false)
+    })
+    it('should return false if host is on the opt-out list', async function () {
+      state.noIntegrationsHostnames = ['example.com', 'pl.wikipedia.org']
+      const url = 'https://pl.wikipedia.org/wiki/Wikipedia:Strona_g%C5%82%C3%B3wna'
+      expect(state.activeIntegrations(url)).to.equal(false)
+    })
+    it('should return false if parent host of a subdomain is on the opt-out list', async function () {
+      state.noIntegrationsHostnames = ['wikipedia.org']
+      const url = 'https://pl.wikipedia.org/wiki/Wikipedia:Strona_g%C5%82%C3%B3wna'
+      expect(state.activeIntegrations(url)).to.equal(false)
+    })
+  })
+})

From 87e270535780e2057f73f86079923be75a5b396c Mon Sep 17 00:00:00 2001
From: Marcin Rataj <lidel@lidel.org>
Date: Wed, 11 Dec 2019 17:24:46 +0100
Subject: [PATCH 2/3] =?UTF-8?q?chore:=20migrate=20noRedirectHostnames=20?=
 =?UTF-8?q?=E2=86=92=20noIntegrationsHostnames?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 add-on/src/lib/options.js | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/add-on/src/lib/options.js b/add-on/src/lib/options.js
index 94ba7af43..dc8ee2b00 100644
--- a/add-on/src/lib/options.js
+++ b/add-on/src/lib/options.js
@@ -132,4 +132,11 @@ exports.migrateOptions = async (storage) => {
       ipfsNodeConfig: buildDefaultIpfsNodeConfig()
     })
   }
+  // ~ v2.9.x: migrating noRedirectHostnames → noIntegrationsHostnames
+  // https://github.com/ipfs-shipyard/ipfs-companion/pull/830
+  const { noRedirectHostnames } = await storage.get('noRedirectHostnames')
+  if (noRedirectHostnames) {
+    await storage.set({ noIntegrationsHostnames: noRedirectHostnames })
+    await storage.remove('noRedirectHostnames')
+  }
 }

From 8daa50ec25470bb5cebc06078255e29615ff19e7 Mon Sep 17 00:00:00 2001
From: Marcin Rataj <lidel@lidel.org>
Date: Thu, 12 Dec 2019 12:20:08 +0100
Subject: [PATCH 3/3] chore: remove dead code

---
 add-on/src/lib/ipfs-path.js | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/add-on/src/lib/ipfs-path.js b/add-on/src/lib/ipfs-path.js
index 1b95a6362..7aa623758 100644
--- a/add-on/src/lib/ipfs-path.js
+++ b/add-on/src/lib/ipfs-path.js
@@ -254,11 +254,6 @@ function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) {
 
       const directCid = IsIpfs.ipfsPath(result) ? result.split('/')[2] : result
       return directCid
-    },
-
-    // Returns true when opt-out for provided URL exists
-    activeIntegrations (url) {
-      return getState().activeIntegrations(url)
     }
   }