diff --git a/README.md b/README.md index 7bc049262..98dc50772 100644 --- a/README.md +++ b/README.md @@ -69,9 +69,10 @@ More details: [`x-ipfs-path` Header Support in IPFS Companion](https://github.co #### Redirect Opt-Out -It is possible to opt-out from redirect by -a) suspending extension via global toggle -b) including `x-ipfs-companion-no-redirect` in the URL (as a [hash](https://ipfs.io/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR#x-ipfs-companion-no-redirect) or [query](https://ipfs.io/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR?x-ipfs-companion-no-redirect) parameter). +It is possible to opt-out from Gateway redirect by: +- a) suspending redirect via global toggle (see [_Disable All Redirects_](#disable-all-redirects) below) +- b) suspending redirect for via per website opt-out (in [_Active Tab_ section of _Browser Action_](#disable-gateway-redirect-per-website) or _Preferences_) +- c) including `x-ipfs-companion-no-redirect` in the URL (as a [hash](https://ipfs.io/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR#x-ipfs-companion-no-redirect) or [query](https://ipfs.io/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR?x-ipfs-companion-no-redirect) parameter). ### IPFS API as `window.ipfs` @@ -80,11 +81,30 @@ Websites can detect if `window.ipfs` exists and opt-in to use it instead of crea It saves system resources and battery (on mobile), avoids the overhead of peer discovery/connection, enables shared repository access and more! Make sure to read our [notes on `window.ipfs`](https://github.com/ipfs-shipyard/ipfs-companion/blob/master/docs/window.ipfs.md), where we explain it in-depth and provide examples on how to use it your own dapp. -### Toggle IPFS Integrations +### Quick Toggles -> ![screenshot of suspend toggle](https://user-images.githubusercontent.com/157609/42685002-18c7cee4-8692-11e8-9171-970866d91ae0.gif) +The Browser Action pop-up provides handy toggles for often used operations. -The Browser Action pop-up provides a toggle for suspending all active IPFS integrations with a single click. +#### Disable Gateway Redirect Per Website + +> _Active Tab_ actions include option to opt-out current website from Gateway redirect of any IPFS subresources. +> Disabling redirect for DNSLink website will restore original URL as well: +> +> ![per-site-peek 2019-02-26 00-23](https://user-images.githubusercontent.com/157609/53376094-86557500-395d-11e9-837f-a4712aa19236.gif) + +#### Disable All Redirects + +> A handy toggle to disable all gateway redirects while keeping all other features enabled: +> +> ![redirect](https://user-images.githubusercontent.com/157609/53376263-0976cb00-395e-11e9-8536-d83d28ffeee9.gif) + +#### Suspend IPFS Extension + +> The "power" icon can be used to temporarily suspend all IPFS integrations +> (redirects, API status, content scripts, protocol handlers etc). +> Useful during testing. Extension can be re-enabled with a single click: +> +> ![screenshot of suspend toggle](https://user-images.githubusercontent.com/157609/53376196-d6343c00-395d-11e9-83f2-04c16b3a008f.gif) ### IPFS Status and Context Actions diff --git a/add-on/_locales/en/messages.json b/add-on/_locales/en/messages.json index 9b8ffaf98..7374a55f6 100644 --- a/add-on/_locales/en/messages.json +++ b/add-on/_locales/en/messages.json @@ -8,7 +8,7 @@ "description": "A label for IPFS icon (panel_headerIpfsNodeIconLabel)" }, "panel_headerActiveToggleTitle": { - "message": "Global toggle: Suspend all IPFS integrations", + "message": "Toggle all IPFS integrations", "description": "A label for an embedded IPFS node (panel_headerActiveToggleTitle)" }, "panel_statusOffline": { @@ -43,25 +43,41 @@ "message": "Open Web UI", "description": "A menu item in Browser Action pop-up (panel_openWebui)" }, + "panel_redirectToggle": { + "message": "Redirect to Gateway", + "description": "A menu item in Browser Action pop-up (panel_redirectToggle)" + }, + "panel_redirectToggleTooltip": { + "message": "Click to toggle all gateway redirects", + "description": "A menu item in Browser Action pop-up (panel_redirectToggleTooltip)" + }, + "panel_toolsSectionHeader": { + "message": "Tools", + "description": "A menu item in Browser Action pop-up (panel_toolsSectionHeader)" + }, "panel_openPreferences": { "message": "Open Preferences of Browser Extension", "description": "A menu item in Browser Action pop-up (panel_openPreferences)" }, - "panel_switchToCustomGateway": { - "message": "Switch to Custom Gateway", - "description": "A menu item in Browser Action pop-up (panel_switchToCustomGateway)" + "panel_activeTabSectionHeader": { + "message": "Active Tab", + "description": "A menu item in Browser Action pop-up (panel_activeTabSiteRedirectEnable)" }, - "panel_switchToPublicGateway": { - "message": "Switch to Public Gateway", - "description": "A menu item in Browser Action pop-up (panel_switchToPublicGateway)" + "panel_activeTabSiteRedirectToggle": { + "message": "Redirect on $1", + "description": "A menu item in Browser Action pop-up (panel_activeTabSiteRedirectToggle)" + }, + "panel_activeTabSiteRedirectToggleTooltip": { + "message": "Click to toggle gateway redirects on $1", + "description": "A menu item tooltip in Browser Action pop-up (panel_activeTabSiteRedirectToggleTooltip)" }, "panel_pinCurrentIpfsAddress": { "message": "Pin IPFS Resource", "description": "A menu item in Browser Action pop-up (panel_pinCurrentIpfsAddress)" }, - "panel_unpinCurrentIpfsAddress": { - "message": "Unpin IPFS Resource", - "description": "A menu item in Browser Action pop-up (panel_unpinCurrentIpfsAddress)" + "panel_pinCurrentIpfsAddressTooltip": { + "message": "Pinning a CID tells your IPFS node that this data is important and mustn’t be thrown away.", + "description": "A menu item tooltip in Browser Action pop-up (panel_pinCurrentIpfsAddressTooltip)" }, "panelCopy_currentIpfsAddress": { "message": "Copy IPFS Path", @@ -251,6 +267,14 @@ "message": "Redirect requests for IPFS resources to the Custom gateway", "description": "An option description on the Preferences screen (option_useCustomGateway_description)" }, + "option_noRedirectHostnames_title": { + "message": "Redirect Opt-Outs", + "description": "An option title on the Preferences screen (option_noRedirectHostnames_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.", + "description": "An option description on the Preferences screen (option_noRedirectHostnames_description)" + }, "option_publicGatewayUrl_title": { "message": "Default Public Gateway", "description": "An option title on the Preferences screen (option_publicGatewayUrl_title)" diff --git a/add-on/src/lib/dnslink.js b/add-on/src/lib/dnslink.js index 1c500852a..88789f535 100644 --- a/add-on/src/lib/dnslink.js +++ b/add-on/src/lib/dnslink.js @@ -175,6 +175,29 @@ module.exports = function createDnslinkResolver (getState) { } const fqdn = url.hostname return `/ipns/${fqdn}${url.pathname}${url.search}${url.hash}` + }, + + // Test if URL contains a valid DNSLink FQDN + // in url.hostname OR in url.pathname (/ipns/) + // and return matching FQDN if present + findDNSLinkHostname (url) { + const { hostname, pathname } = new URL(url) + // check //foo.tld/ipns/ + if (IsIpfs.ipnsPath(pathname)) { + // we may have false-positives here, so we do additional checks below + const ipnsRoot = pathname.match(/^\/ipns\/([^/]+)/)[1] + // console.log('findDNSLinkHostname ==> inspecting IPNS root', ipnsRoot) + // Ignore PeerIDs, match DNSLink only + if (!IsIpfs.cid(ipnsRoot) && dnslinkResolver.readAndCacheDnslink(ipnsRoot)) { + // console.log('findDNSLinkHostname ==> found DNSLink for FQDN in url.pathname: ', ipnsRoot) + return ipnsRoot + } + } + // check ///foo/bar + if (dnslinkResolver.readAndCacheDnslink(hostname)) { + // console.log('findDNSLinkHostname ==> found DNSLink for url.hostname', hostname) + return hostname + } } } diff --git a/add-on/src/lib/ipfs-companion.js b/add-on/src/lib/ipfs-companion.js index c3ddd6766..8617d4304 100644 --- a/add-on/src/lib/ipfs-companion.js +++ b/add-on/src/lib/ipfs-companion.js @@ -213,13 +213,17 @@ module.exports = async function init () { async function sendStatusUpdateToBrowserAction () { if (!browserActionPort) return + const dropSlash = url => url.replace(/\/$/, '') const info = { active: state.active, ipfsNodeType: state.ipfsNodeType, peerCount: state.peerCount, - gwURLString: state.gwURLString, - pubGwURLString: state.pubGwURLString, + gwURLString: dropSlash(state.gwURLString), + pubGwURLString: dropSlash(state.pubGwURLString), webuiRootUrl: state.webuiRootUrl, + apiURLString: dropSlash(state.apiURLString), + redirect: state.redirect, + noRedirectHostnames: state.noRedirectHostnames, currentTab: await browser.tabs.query({ active: true, currentWindow: true }).then(tabs => tabs[0]) } try { @@ -231,7 +235,12 @@ module.exports = async function init () { info.gatewayVersion = null } if (info.currentTab) { - info.ipfsPageActionsContext = ipfsPathValidator.isIpfsPageActionsContext(info.currentTab.url) + const url = info.currentTab.url + 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.isRedirectContext = info.currentFqdn && ipfsPathValidator.isRedirectPageActionsContext(url) } // Still here? if (browserActionPort) { @@ -641,6 +650,7 @@ module.exports = async function init () { case 'automaticMode': case 'detectIpfsPathHeader': case 'preloadAtPublicGateway': + case 'noRedirectHostnames': state[key] = change.newValue break } diff --git a/add-on/src/lib/ipfs-path.js b/add-on/src/lib/ipfs-path.js index 79200a545..d49278b9e 100644 --- a/add-on/src/lib/ipfs-path.js +++ b/add-on/src/lib/ipfs-path.js @@ -64,8 +64,19 @@ function createIpfsPathValidator (getState, dnsLink) { }, // Test if actions such as 'copy URL', 'pin/unpin' should be enabled for the URL + // TODO: include hostname check for DNSLink and display option to copy CID even if no redirect isIpfsPageActionsContext (url) { return (IsIpfs.url(url) && !url.startsWith(getState().apiURLString)) || IsIpfs.subdomain(url) + }, + + // Test if actions such as 'per site redirect toggle' should be enabled for the URL + isRedirectPageActionsContext (url) { + const state = getState() + return state.ipfsNodeType === 'external' && // hide with embedded node + (IsIpfs.ipnsUrl(url) || // show on /ipns/ + (url.startsWith('http') && // hide on non-HTTP pages + !url.startsWith(state.gwURLString) && // hide on /ipfs/* + !url.startsWith(state.apiURLString))) // hide on api port } } @@ -110,6 +121,7 @@ function validIpnsPath (path, dnsLink) { // console.log('==> IPNS is a valid CID', ipnsRoot) return true } + // then see if there is an DNSLink entry for 'ipnsRoot' hostname if (dnsLink.readAndCacheDnslink(ipnsRoot)) { // console.log('==> IPNS for FQDN with valid dnslink: ', ipnsRoot) return true diff --git a/add-on/src/lib/ipfs-request.js b/add-on/src/lib/ipfs-request.js index e3fd40bbb..28551f8aa 100644 --- a/add-on/src/lib/ipfs-request.js +++ b/add-on/src/lib/ipfs-request.js @@ -65,6 +65,22 @@ 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 + 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 && request.url !== parentUrl ? new URL(parentUrl).hostname : null + if (state.noRedirectHostnames.some(optout => + fqdn.endsWith(optout) || (parentFqdn && parentFqdn.endsWith(optout) + ))) { + ignore(request.requestId) + } + // additional checks limited to requests for root documents + if (request.type === 'main_frame') { + // lazily trigger DNSLink lookup (will do anything only if status for root domain is not in cache) + if (state.dnslinkPolicy && dnslinkResolver.canLookupURL(request.url)) { + dnslinkResolver.preloadDnslink(request.url) + } + } return isIgnored(request.requestId) } diff --git a/add-on/src/lib/options.js b/add-on/src/lib/options.js index a0023b683..36c28e530 100644 --- a/add-on/src/lib/options.js +++ b/add-on/src/lib/options.js @@ -1,5 +1,7 @@ 'use strict' +const isFQDN = require('is-fqdn') + exports.optionDefaults = Object.freeze({ active: true, // global ON/OFF switch, overrides everything else ipfsNodeType: 'external', // or 'embedded' @@ -12,6 +14,7 @@ exports.optionDefaults = Object.freeze({ }, null, 2), publicGatewayUrl: 'https://ipfs.io', useCustomGateway: true, + noRedirectHostnames: [], automaticMode: true, linkify: false, dnslinkPolicy: 'best-effort', @@ -22,7 +25,7 @@ exports.optionDefaults = Object.freeze({ customGatewayUrl: 'http://127.0.0.1:8080', ipfsApiUrl: 'http://127.0.0.1:5001', ipfsApiPollMs: 3000, - ipfsProxy: true + ipfsProxy: true // window.ipfs }) // `storage` should be a browser.storage.local or similar @@ -64,6 +67,23 @@ function normalizeGatewayURL (url) { exports.normalizeGatewayURL = normalizeGatewayURL exports.safeURL = (url) => new URL(normalizeGatewayURL(url)) +// convert JS array to multiline textarea +function hostArrayCleanup (array) { + array = array.map(host => host.trim().toLowerCase()) + array = [...new Set(array)] // dedup + array = array.filter(Boolean).filter(isFQDN) + array.sort() + return array +} +function hostArrayToText (array) { + return hostArrayCleanup(array).join('\n') +} +function hostTextToArray (text) { + return hostArrayCleanup(text.split('\n')) +} +exports.hostArrayToText = hostArrayToText +exports.hostTextToArray = hostTextToArray + exports.migrateOptions = async (storage) => { // <= v2.4.4 // DNSLINK: convert old on/off 'dnslink' flag to text-based 'dnslinkPolicy' diff --git a/add-on/src/options/forms/gateways-form.js b/add-on/src/options/forms/gateways-form.js index dddc87a41..df993b9cd 100644 --- a/add-on/src/options/forms/gateways-form.js +++ b/add-on/src/options/forms/gateways-form.js @@ -3,7 +3,7 @@ const browser = require('webextension-polyfill') const html = require('choo/html') -const { normalizeGatewayURL } = require('../../lib/options') +const { normalizeGatewayURL, hostTextToArray, hostArrayToText } = require('../../lib/options') // Warn about mixed content issues when changing the gateway // https://github.com/ipfs-shipyard/ipfs-companion/issues/648 @@ -13,19 +13,40 @@ function gatewaysForm ({ ipfsNodeType, customGatewayUrl, useCustomGateway, + noRedirectHostnames, publicGatewayUrl, onOptionChange }) { const onCustomGatewayUrlChange = onOptionChange('customGatewayUrl', normalizeGatewayURL) const onUseCustomGatewayChange = onOptionChange('useCustomGateway') const onPublicGatewayUrlChange = onOptionChange('publicGatewayUrl', normalizeGatewayURL) + const onNoRedirectHostnamesChange = onOptionChange('noRedirectHostnames', hostTextToArray) const mixedContentWarning = !secureContextUrl.test(customGatewayUrl) + const supportRedirectToCustomGateway = ipfsNodeType === 'external' return html`
${browser.i18n.getMessage('option_header_gateways')} - ${ipfsNodeType === 'external' ? html` +
+ + +
+ ${supportRedirectToCustomGateway ? html`
` : null} - ${ipfsNodeType === 'external' ? html` + ${supportRedirectToCustomGateway ? html`
` : null} -
- - -
+ ${supportRedirectToCustomGateway ? html` +
+ + +
+ ` : null}
` diff --git a/add-on/src/options/forms/ipfs-node-form.js b/add-on/src/options/forms/ipfs-node-form.js index 3a86b36f6..3ae72e8ce 100644 --- a/add-on/src/options/forms/ipfs-node-form.js +++ b/add-on/src/options/forms/ipfs-node-form.js @@ -46,7 +46,7 @@ function ipfsNodeForm ({ ipfsNodeType, ipfsNodeConfig, onOptionChange }) {
${browser.i18n.getMessage('option_ipfsNodeConfig_description')}
- + ` : null} diff --git a/add-on/src/options/page.js b/add-on/src/options/page.js index df0cb3ce0..ecba7bcda 100644 --- a/add-on/src/options/page.js +++ b/add-on/src/options/page.js @@ -60,6 +60,7 @@ module.exports = function optionsPage (state, emit) { customGatewayUrl: state.options.customGatewayUrl, useCustomGateway: state.options.useCustomGateway, publicGatewayUrl: state.options.publicGatewayUrl, + noRedirectHostnames: state.options.noRedirectHostnames, onOptionChange })} ${state.options.ipfsNodeType === 'external' ? apiForm({ diff --git a/add-on/src/popup/browser-action/browser-action.css b/add-on/src/popup/browser-action/browser-action.css index 608d8e0a4..f8be8309d 100644 --- a/add-on/src/popup/browser-action/browser-action.css +++ b/add-on/src/popup/browser-action/browser-action.css @@ -1,11 +1,23 @@ @import url('/ui-kit/tachyons.css'); @import url('/ui-kit/ipfs.css'); +@import url('/ui-kit/mdc.switch.min.css'); @import url('../heartbeat.css'); .bg-near-white--hover:hover { background-color: #F4F4F4; } +.header-icon:active { + color: #edf0f4; + transform: translateY(4px); +} +.header-icon[disabled], +.header-icon[disabled]:active { + cursor: not-allowed; + pointer-events: none; + transform: none; +} + .outline-0--focus:focus { outline: 0; } @@ -31,3 +43,7 @@ from { opacity: 0; } to { opacity: 1; } } + +.mdc-switch { + --mdc-theme-secondary: #244e66 /* navy-muted */ +} diff --git a/add-on/src/popup/browser-action/context-actions.js b/add-on/src/popup/browser-action/context-actions.js index df868c9d7..757d0479b 100644 --- a/add-on/src/popup/browser-action/context-actions.js +++ b/add-on/src/popup/browser-action/context-actions.js @@ -4,10 +4,16 @@ const browser = require('webextension-polyfill') const html = require('choo/html') const navItem = require('./nav-item') +const navHeader = require('./nav-header') const { contextMenuCopyAddressAtPublicGw, contextMenuCopyRawCid, contextMenuCopyCanonicalAddress } = require('../../lib/context-menus') -module.exports = function contextActions ({ +// Context Actions are displayed in Browser Action and Page Action (FF only) +function contextActions ({ active, + redirect, + isRedirectContext, + currentFqdn, + currentTabRedirectOptOut, ipfsNodeType, isIpfsContext, isPinning, @@ -15,14 +21,16 @@ module.exports = function contextActions ({ isPinned, isIpfsOnline, isApiAvailable, + onToggleSiteRedirect, onCopy, onPin, onUnPin }) { - if (!isIpfsContext) return null const activePinControls = active && isIpfsOnline && isApiAvailable && !(isPinning || isUnPinning) - return html` -
+ + const renderIpfsContextItems = () => { + if (!isIpfsContext) return + return html`
${navItem({ text: browser.i18n.getMessage(contextMenuCopyAddressAtPublicGw), onClick: () => onCopy(contextMenuCopyAddressAtPublicGw) @@ -36,20 +44,52 @@ module.exports = function contextActions ({ disabled: !activePinControls, onClick: () => onCopy(contextMenuCopyRawCid) })} - ${!isPinned ? ( - navItem({ - text: browser.i18n.getMessage('panel_pinCurrentIpfsAddress'), - disabled: !activePinControls, - onClick: onPin - }) - ) : null} - ${isPinned ? ( - navItem({ - text: browser.i18n.getMessage('panel_unpinCurrentIpfsAddress'), - disabled: !activePinControls, - onClick: onUnPin - }) - ) : null} + ${navItem({ + text: browser.i18n.getMessage('panel_pinCurrentIpfsAddress'), + title: browser.i18n.getMessage('panel_pinCurrentIpfsAddressTooltip'), + disabled: !activePinControls, + switchValue: isPinned, + onClick: isPinned ? onUnPin : onPin + })} +
+ ` + } + + const renderSiteRedirectToggle = () => { + if (!isRedirectContext) return + return html` + ${navItem({ + text: browser.i18n.getMessage('panel_activeTabSiteRedirectToggle', currentFqdn), + title: browser.i18n.getMessage('panel_activeTabSiteRedirectToggleTooltip', currentFqdn), + style: 'truncate', + disabled: !(active && redirect), + switchValue: active && redirect && !currentTabRedirectOptOut, + onClick: onToggleSiteRedirect + })} + ` + } + + return html` +
+ ${renderSiteRedirectToggle()} + ${renderIpfsContextItems()}
` } +module.exports.contextActions = contextActions + +// "Active Tab" section is displayed in Browser Action only +// if redirect can be toggled or current tab has any IPFS Context Actions +function activeTabActions (state) { + const showActiveTabSection = (state.isRedirectContext) || state.isIpfsContext + if (!showActiveTabSection) return + return html` +
+ ${navHeader('panel_activeTabSectionHeader')} +
+ ${contextActions(state)} +
+
+ ` +} +module.exports.activeTabActions = activeTabActions diff --git a/add-on/src/popup/browser-action/gateway-status.js b/add-on/src/popup/browser-action/gateway-status.js index bc1eaa44c..57815f7f7 100644 --- a/add-on/src/popup/browser-action/gateway-status.js +++ b/add-on/src/popup/browser-action/gateway-status.js @@ -18,15 +18,11 @@ function statusEntry ({ label, labelLegend, value, check, itemClass = '', valueC } module.exports = function gatewayStatus ({ - active, - onToggleActive, ipfsApiUrl, gatewayAddress, gatewayVersion, swarmPeers, - isIpfsOnline, - ipfsNodeType, - redirectEnabled + ipfsNodeType }) { const api = ipfsApiUrl && ipfsNodeType === 'embedded' ? 'js-ipfs' : ipfsApiUrl return html` diff --git a/add-on/src/popup/browser-action/icon.js b/add-on/src/popup/browser-action/icon.js index 443a60609..b419fd4a1 100644 --- a/add-on/src/popup/browser-action/icon.js +++ b/add-on/src/popup/browser-action/icon.js @@ -6,7 +6,7 @@ const browser = require('webextension-polyfill') function icon ({ svg, title, active, action }) { return html` - ` } diff --git a/add-on/src/popup/browser-action/operations.js b/add-on/src/popup/browser-action/operations.js index dea80e5c2..c16249d2d 100644 --- a/add-on/src/popup/browser-action/operations.js +++ b/add-on/src/popup/browser-action/operations.js @@ -7,39 +7,19 @@ const navItem = require('./nav-item') module.exports = function operations ({ active, + redirect, ipfsNodeType, - isIpfsOnline, - redirectEnabled, - isApiAvailable, - onQuickUpload, - onOpenWebUi, - onToggleRedirect + onToggleGlobalRedirect }) { - const activeQuickUpload = active && isIpfsOnline && isApiAvailable - const activeWebUI = active && isIpfsOnline && ipfsNodeType === 'external' - const activeGatewaySwitch = active && ipfsNodeType === 'external' - + const activeRedirectSwitch = active && ipfsNodeType === 'external' return html` -
- ${navItem({ - text: browser.i18n.getMessage('panel_quickUpload'), - addClass: 'b', - disabled: !activeQuickUpload, - onClick: onQuickUpload - })} - ${navItem({ - text: browser.i18n.getMessage( - redirectEnabled && activeGatewaySwitch - ? 'panel_switchToPublicGateway' - : 'panel_switchToCustomGateway' - ), - disabled: !activeGatewaySwitch, - onClick: onToggleRedirect - })} +
${navItem({ - text: browser.i18n.getMessage('panel_openWebui'), - disabled: !activeWebUI, - onClick: onOpenWebUi + text: browser.i18n.getMessage('panel_redirectToggle'), + title: browser.i18n.getMessage('panel_redirectToggleTooltip'), + disabled: !activeRedirectSwitch, + switchValue: redirect && activeRedirectSwitch, + onClick: onToggleGlobalRedirect })}
` diff --git a/add-on/src/popup/browser-action/page.js b/add-on/src/popup/browser-action/page.js index 5910c6a1e..73f804390 100644 --- a/add-on/src/popup/browser-action/page.js +++ b/add-on/src/popup/browser-action/page.js @@ -3,8 +3,9 @@ const html = require('choo/html') const header = require('./header') -const contextActions = require('./context-actions') +const { activeTabActions } = require('./context-actions') const operations = require('./operations') +const tools = require('./tools') // Render the browser action page: // Passed current app `state` from the store and `emit`, a function to create @@ -17,23 +18,21 @@ module.exports = function browserActionPage (state, emit) { const onQuickUpload = () => emit('quickUpload') const onOpenWebUi = () => emit('openWebUi') const onOpenPrefs = () => emit('openPrefs') - const onToggleRedirect = () => emit('toggleRedirect') + const onToggleGlobalRedirect = () => emit('toggleGlobalRedirect') + const onToggleSiteRedirect = () => emit('toggleSiteRedirect') const onToggleNodeType = () => emit('toggleNodeType') const onToggleActive = () => emit('toggleActive') const headerProps = Object.assign({ onToggleNodeType, onToggleActive, onOpenPrefs }, state) - const contextActionsProps = Object.assign({ onCopy, onPin, onUnPin }, state) - const opsProps = Object.assign({ onQuickUpload, onOpenWebUi, onToggleRedirect }, state) + const activeTabActionsProps = Object.assign({ onToggleSiteRedirect, onCopy, onPin, onUnPin }, state) + const opsProps = Object.assign({ onQuickUpload, onOpenWebUi, onToggleGlobalRedirect }, state) return html`
${header(headerProps)} -
- ${contextActions(contextActionsProps)} -
-
- ${operations(opsProps)} -
+ ${operations(opsProps)} + ${activeTabActions(activeTabActionsProps)} + ${tools(opsProps)}
` } diff --git a/add-on/src/popup/browser-action/redirect-icon.js b/add-on/src/popup/browser-action/redirect-icon.js new file mode 100644 index 000000000..0ca2b7a9f --- /dev/null +++ b/add-on/src/popup/browser-action/redirect-icon.js @@ -0,0 +1,27 @@ +'use strict' +/* eslint-env browser, webextensions */ + +const html = require('choo/html') +const icon = require('./icon') + +function redirectIcon ({ + active, + title, + action, + size = '2rem' +}) { + const svg = html` + + + + ` + return icon({ + svg, + title, + active, + action + }) +} +module.exports = redirectIcon diff --git a/add-on/src/popup/browser-action/store.js b/add-on/src/popup/browser-action/store.js index 3271665f3..e0d9279b8 100644 --- a/add-on/src/popup/browser-action/store.js +++ b/add-on/src/popup/browser-action/store.js @@ -2,21 +2,23 @@ /* eslint-env browser, webextensions */ const browser = require('webextension-polyfill') +const IsIpfs = require('is-ipfs') const { safeIpfsPath, trimHashAndSearch } = require('../../lib/ipfs-path') const { contextMenuCopyAddressAtPublicGw, contextMenuCopyRawCid, contextMenuCopyCanonicalAddress } = require('../../lib/context-menus') // The store contains and mutates the state for the app module.exports = (state, emitter) => { Object.assign(state, { - // Global ON/OFF + // Global toggles active: true, - // UI state - isIpfsContext: false, + redirect: true, + // UI contexts + isIpfsContext: false, // Active Tab represents IPFS resource + isRedirectContext: false, // Active Tab or its subresources could be redirected isPinning: false, isUnPinning: false, isPinned: false, - currentTab: null, - // IPFS status + // IPFS details ipfsNodeType: 'external', isIpfsOnline: false, ipfsApiUrl: null, @@ -24,8 +26,12 @@ module.exports = (state, emitter) => { gatewayAddress: null, swarmPeers: null, gatewayVersion: null, - redirectEnabled: false, - isApiAvailable: false + isApiAvailable: false, + // isRedirectContext + currentTab: null, + currentFqdn: null, + currentDnslinkFqdn: null, + noRedirectHostnames: [] }) let port @@ -37,11 +43,11 @@ module.exports = (state, emitter) => { port = browser.runtime.connect({ name: 'browser-action-port' }) port.onMessage.addListener(async (message) => { if (message.statusUpdate) { - let status = message.statusUpdate + const status = message.statusUpdate console.log('In browser action, received message from background:', message) await updateBrowserActionState(status) emitter.emit('render') - if (status.ipfsPageActionsContext) { + if (status.isIpfsContext) { // calculating pageActions states is expensive (especially pin-related checks) // we update them in separate step to keep UI snappy await updatePageActionsState(status) @@ -142,20 +148,64 @@ module.exports = (state, emitter) => { }) }) - emitter.on('toggleRedirect', async () => { - const enabled = state.redirectEnabled - state.redirectEnabled = !enabled - state.gatewayAddress = '…' + emitter.on('toggleGlobalRedirect', async () => { + const redirectEnabled = state.redirect + // If all integrations were suspended.. + if (!state.active) { + // ..clicking on 'inactive' toggle implies user wants to go online + emitter.emit('toggleActive') + // if redirect was already on, then we dont want to disable it, as it would be bad UX + if (redirectEnabled) return + } + state.redirect = !redirectEnabled + state.gatewayAddress = state.redirect ? state.gwURLString : state.pubGwURLString emitter.emit('render') try { - await browser.storage.local.set({ useCustomGateway: !enabled }) + await browser.storage.local.set({ useCustomGateway: !redirectEnabled }) } catch (error) { console.error(`Unable to update redirect state due to ${error}`) - state.redirectEnabled = enabled + state.redirect = redirectEnabled } + emitter.emit('render') + }) + emitter.on('toggleSiteRedirect', async () => { + state.currentTabRedirectOptOut = !state.currentTabRedirectOptOut emitter.emit('render') + + try { + let noRedirectHostnames = state.noRedirectHostnames + // if we are on /ipns/fqdn.tld/ then use hostname from DNSLink + let fqdn = state.currentDnslinkFqdn || state.currentFqdn + if (noRedirectHostnames.includes(fqdn)) { + noRedirectHostnames = noRedirectHostnames.filter(host => !host.endsWith(fqdn)) + } else { + noRedirectHostnames.push(fqdn) + } + // console.dir('toggleSiteRedirect', state) + await browser.storage.local.set({ noRedirectHostnames }) + + // Reload the current tab to apply updated redirect preference + if (!state.currentDnslinkFqdn || !IsIpfs.ipnsUrl(state.currentTab.url)) { + // No DNSLink, reload URL as-is + await browser.tabs.reload(state.currentTab.id) + } else { + // DNSLinked websites require URL change + // from http?://gateway.tld/ipns/{fqdn}/some/path + // to http://{fqdn}/some/path + // (defaulting to http: https websites will have HSTS or a redirect) + const originalUrl = state.currentTab.url.replace(/^.*\/ipns\//, 'http://') + await browser.tabs.update(state.currentTab.id, { + // FF only: loadReplace: true, + url: originalUrl + }) + } + } catch (error) { + console.error(`Unable to update redirect state due to ${error}`) + emitter.emit('render') + } + // window.close() }) emitter.on('toggleNodeType', async () => { @@ -175,28 +225,22 @@ module.exports = (state, emitter) => { const prev = state.active state.active = !prev if (!state.active) { - const options = await browser.storage.local.get() - state.gatewayAddress = options.publicGatewayUrl + state.gatewayAddress = state.pubGwURLString state.ipfsApiUrl = null state.gatewayVersion = null state.swarmPeers = null state.isIpfsOnline = false } - emitter.emit('render') try { await browser.storage.local.set({ active: state.active }) } catch (error) { console.error(`Unable to update global Active flag due to ${error}`) state.active = prev - emitter.emit('render') } + emitter.emit('render') }) async function updatePageActionsState (status) { - // Check if current page is an IPFS one - state.isIpfsContext = status.ipfsPageActionsContext || false - state.currentTab = status.currentTab || null - // browser.pageAction-specific items that can be rendered earlier (snappy UI) requestAnimationFrame(async () => { const tabId = state.currentTab ? { tabId: state.currentTab.id } : null @@ -220,28 +264,27 @@ module.exports = (state, emitter) => { async function updateBrowserActionState (status) { if (status) { - const options = await browser.storage.local.get() - state.active = status.active - if (state.active && options.useCustomGateway && (options.ipfsNodeType !== 'embedded')) { - state.gatewayAddress = options.customGatewayUrl + // Copy all attributes + Object.assign(state, status) + + if (state.active && status.redirect && (status.ipfsNodeType !== 'embedded')) { + state.gatewayAddress = status.gwURLString } else { - state.gatewayAddress = options.publicGatewayUrl + state.gatewayAddress = status.pubGwURLString } - state.ipfsNodeType = status.ipfsNodeType - state.redirectEnabled = state.active && options.useCustomGateway // Upload requires access to the background page (https://github.com/ipfs-shipyard/ipfs-companion/issues/477) state.isApiAvailable = state.active && !!(await browser.runtime.getBackgroundPage()) && !browser.extension.inIncognitoContext // https://github.com/ipfs-shipyard/ipfs-companion/issues/243 state.swarmPeers = !state.active || status.peerCount === -1 ? null : status.peerCount state.isIpfsOnline = state.active && status.peerCount > -1 state.gatewayVersion = state.active && status.gatewayVersion ? status.gatewayVersion : null - state.ipfsApiUrl = state.active ? options.ipfsApiUrl : null - state.webuiRootUrl = status.webuiRootUrl + state.ipfsApiUrl = state.active ? status.apiURLString : null } else { state.ipfsNodeType = 'external' state.swarmPeers = null state.isIpfsOnline = false state.gatewayVersion = null state.isIpfsContext = false + state.isRedirectContext = false } } diff --git a/add-on/src/popup/browser-action/switch-toggle.js b/add-on/src/popup/browser-action/switch-toggle.js new file mode 100644 index 000000000..987c911c8 --- /dev/null +++ b/add-on/src/popup/browser-action/switch-toggle.js @@ -0,0 +1,21 @@ +'use strict' +/* eslint-env browser, webextensions */ + +const html = require('choo/html') + +function switchToggle ({ checked, disabled, style }) { + if (typeof checked === 'undefined') return + return html` +
+
+
+
+ +
+
+
+ ` +} + +module.exports = switchToggle diff --git a/add-on/src/popup/browser-action/tools.js b/add-on/src/popup/browser-action/tools.js new file mode 100644 index 000000000..2c3ae0180 --- /dev/null +++ b/add-on/src/popup/browser-action/tools.js @@ -0,0 +1,38 @@ +'use strict' +/* eslint-env browser, webextensions */ + +const browser = require('webextension-polyfill') +const html = require('choo/html') +const navItem = require('./nav-item') +const navHeader = require('./nav-header') + +module.exports = function tools ({ + active, + ipfsNodeType, + isIpfsOnline, + isApiAvailable, + onQuickUpload, + onOpenWebUi +}) { + const activeQuickUpload = active && isIpfsOnline && isApiAvailable + const activeWebUI = active && isIpfsOnline && ipfsNodeType === 'external' + + return html` +
+ ${navHeader('panel_toolsSectionHeader')} +
+ ${navItem({ + text: browser.i18n.getMessage('panel_quickUpload'), + style: 'b', + disabled: !activeQuickUpload, + onClick: onQuickUpload + })} + ${navItem({ + text: browser.i18n.getMessage('panel_openWebui'), + disabled: !activeWebUI, + onClick: onOpenWebUi + })} +
+
+ ` +} diff --git a/add-on/src/popup/page-action/page.js b/add-on/src/popup/page-action/page.js index a43308e58..b94d4b16b 100644 --- a/add-on/src/popup/page-action/page.js +++ b/add-on/src/popup/page-action/page.js @@ -3,7 +3,7 @@ const html = require('choo/html') const header = require('./header') -const contextActions = require('../browser-action/context-actions') +const { contextActions } = require('../browser-action/context-actions') // Render the page-action page: // Passed current app `state` from the store and `emit`, a function to create @@ -12,7 +12,9 @@ module.exports = function pageActionPage (state, emit) { const onCopy = (copyAction) => emit('copy', copyAction) const onPin = () => emit('pin') const onUnPin = () => emit('unPin') - const contextActionsProps = Object.assign({ onCopy, onPin, onUnPin }, state) + const onToggleSiteRedirect = () => emit('toggleSiteRedirect') + + const contextActionsProps = Object.assign({ onCopy, onPin, onUnPin, onToggleSiteRedirect }, state) // Instant init: page-action is shown only in ipfsContext contextActionsProps.isIpfsContext = true diff --git a/package.json b/package.json index 3835cc0fa..dea641eea 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "build:copy:src": "shx mkdir -p add-on/dist && shx cp -R add-on/src/* add-on/dist", "build:copy:ui-kit": "run-s build:copy:ui-kit:*", "build:copy:ui-kit:ipfs-css": "run-s build:copy:ui-kit:ipfs-css:*", - "build:copy:ui-kit:ipfs-css:css": "shx mkdir -p add-on/ui-kit && shx cp node_modules/ipfs-css/ipfs.css add-on/ui-kit", + "build:copy:ui-kit:ipfs-css:css": "shx mkdir -p add-on/ui-kit && shx cp node_modules/ipfs-css/ipfs.css node_modules/@material/switch/dist/mdc.switch.min.css add-on/ui-kit", "build:copy:ui-kit:ipfs-css:fonts": "shx mkdir -p add-on/ui-kit/fonts && shx cp node_modules/ipfs-css/fonts/* add-on/ui-kit/fonts", "build:copy:ui-kit:ipfs-css:icons": "shx mkdir -p add-on/ui-kit/icons && shx cp node_modules/ipfs-css/icons/* add-on/ui-kit/icons", "build:copy:ui-kit:tachyons": "shx mkdir -p add-on/ui-kit && shx cp node_modules/tachyons/css/tachyons.css add-on/ui-kit", @@ -42,7 +42,7 @@ "bundle:brave": "shx cat add-on/manifest.common.json add-on/manifest.brave.json | json --deep-merge > add-on/manifest.json && web-ext build -a build/brave/", "watch": "npm-run-all build:copy --parallel watch:*", "watch:js": "run-p watch:js:*", - "watch:js:webpack": "webpack --watch --progress -d --devtool inline-source-map", + "watch:js:webpack": "webpack --watch --progress -d --devtool inline-source-map --config ./webpack.config.js", "test": "run-s test:*", "test:functional": " nyc --reporter=lcov --reporter=text mocha --timeout 15000 --require ignore-styles \"test/functional/**/*.test.js\"", "lint": "run-s lint:*", @@ -107,6 +107,7 @@ "webpack-merge": "4.2.1" }, "dependencies": { + "@material/switch": "0.44.1", "choo": "6.13.1", "doc-sniff": "1.0.1", "drag-and-drop-files": "0.0.1", @@ -118,6 +119,7 @@ "ipfs-http-response": "0.2.2", "ipfs-postmsg-proxy": "3.1.1", "ipfsx": "0.17.0", + "is-fqdn": "1.0.1", "is-ipfs": "0.4.8", "is-svg": "3.0.0", "lru-cache": "5.1.1", diff --git a/test/functional/lib/ipfs-path.test.js b/test/functional/lib/ipfs-path.test.js index b249644a9..641cd9215 100644 --- a/test/functional/lib/ipfs-path.test.js +++ b/test/functional/lib/ipfs-path.test.js @@ -100,23 +100,58 @@ describe('ipfs-path.js', function () { }) }) describe('isIpfsPageActionsContext', function () { - it('should return true for URL at IPFS Gateway', function () { - const url = 'https://ipfs.io/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR?argTest#hashTest' + it('should return true for URL at a Gateway', function () { + const url = 'https://example.com/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR?argTest#hashTest' expect(ipfsPathValidator.isIpfsPageActionsContext(url)).to.equal(true) }) - it('should return true for URL at IPFS Gateway with Base32 CIDv1 in subdomain', function () { + it('should return true for URL at a Gateway with Base32 CIDv1 in subdomain', function () { // context-actions are shown on publick gateways that use CID in subdomain as well const url = 'http://bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy.ipfs.dweb.link/' expect(ipfsPathValidator.isIpfsPageActionsContext(url)).to.equal(true) }) - it('should return false for URL at IPFS Gateway with Base58 CIDv0 in subdomain', function () { - // context-actions are shown on publick gateways that use CID in subdomain as well + it('should return false for URL at a Gateway with Base58 CIDv0 in subdomain', function () { + // should not be allowed, but who knows const url = 'http://QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR.ipfs.dweb.link/' expect(ipfsPathValidator.isIpfsPageActionsContext(url)).to.equal(false) }) it('should return false for non-IPFS URL', function () { - const url = 'https://ipfs.io/ipfs/NotACid?argTest#hashTest' + const url = 'https://example.com/ipfs/NotACid?argTest#hashTest' expect(ipfsPathValidator.isIpfsPageActionsContext(url)).to.equal(false) }) }) + describe('isRedirectPageActionsContext', function () { + it('should return true for /ipfs/ URL at a Gateway', function () { + const url = 'https://example.com/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR?argTest#hashTest' + expect(ipfsPathValidator.isRedirectPageActionsContext(url)).to.equal(true) + }) + it('should return true for /ipns/ URL at Local Gateway', function () { + const url = `${state.gwURL}ipns/docs.ipfs.io/?argTest#hashTest` + expect(ipfsPathValidator.isRedirectPageActionsContext(url)).to.equal(true) + }) + it('should return false for /ipfs/ URL at Local Gateway', function () { + const url = `${state.gwURL}/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR?argTest#hashTest` + expect(ipfsPathValidator.isRedirectPageActionsContext(url)).to.equal(false) + }) + it('should return false for IPFS content loaded from IPFS API port', function () { + const url = `${state.apiURL}ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR?argTest#hashTest` + expect(ipfsPathValidator.isRedirectPageActionsContext(url)).to.equal(false) + }) + it('should return true for URL at IPFS Gateway with Base32 CIDv1 in subdomain', function () { + // context-actions are shown on publick gateways that use CID in subdomain as well + const url = 'http://bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy.ipfs.dweb.link/' + expect(ipfsPathValidator.isRedirectPageActionsContext(url)).to.equal(true) + }) + it('should return true for non-IPFS HTTP URL', function () { + const url = 'https://en.wikipedia.org/wiki/Main_Page' + expect(ipfsPathValidator.isRedirectPageActionsContext(url)).to.equal(true) + }) + it('should return true for non-IPFS HTTPS URL', function () { + const url = 'http://en.wikipedia.org/wiki/Main_Page' + expect(ipfsPathValidator.isRedirectPageActionsContext(url)).to.equal(true) + }) + it('should return false for non-HTTP URL', function () { + const url = 'moz-extension://85076b5e-900c-428f-4bf6-e6c1a33042a7/blank-page.html' + expect(ipfsPathValidator.isRedirectPageActionsContext(url)).to.equal(false) + }) + }) }) diff --git a/test/functional/lib/ipfs-request-dnslink.test.js b/test/functional/lib/ipfs-request-dnslink.test.js index 30faf4f7f..35a15ba15 100644 --- a/test/functional/lib/ipfs-request-dnslink.test.js +++ b/test/functional/lib/ipfs-request-dnslink.test.js @@ -219,7 +219,7 @@ describe('modifyRequest processing', function () { // force-clear dnslink cache to enable cache miss dnslinkResolver.clearCache() }) - it('should redirect in onHeadersReceived if DNS TXT record exists and x-ipfs-path header is present', function () { + it('should redirect subrequests in onHeadersReceived if DNS TXT record exists and x-ipfs-path header is present', function () { // clear dnslink cache to ensure miss dnslinkResolver.clearCache() // stub existence of a valid DNS record @@ -227,6 +227,7 @@ describe('modifyRequest processing', function () { dnslinkResolver.readDnslinkFromTxtRecord = sinon.stub().withArgs(fqdn).returns('/ipfs/QmbfimSwTuCvGL8XBr3yk1iCjqgk2co2n21cWmcQohymDd') // const request = url2request('http://explore.ipld.io/index.html?argTest#hashTest') + request.type = 'sub_frame' // we test a subrequests because main_frame gets early DNSLink preload in onBeforeRequest expect(modifyRequest.onBeforeRequest(request)).to.equal(undefined) // simulate presence of x-ipfs-path header returned by HTTP gateway request.responseHeaders = [{ name: 'X-Ipfs-Path', value: '/ipfs/QmbfimSwTuCvGL8XBr3yk1iCjqgk2co2n21cWmcQohymDd' }] @@ -241,6 +242,7 @@ describe('modifyRequest processing', function () { dnslinkResolver.readDnslinkFromTxtRecord = sinon.stub().withArgs(fqdn).returns(false) // const request = url2request('http://explore.ipld.io/index.html?argTest#hashTest') + request.type = 'sub_frame' // we test a subrequest here because main_frame gets early DNSLink preload in onBeforeRequest expect(modifyRequest.onBeforeRequest(request)).to.equal(undefined) // simulate presence of x-ipfs-path header returned by HTTP gateway request.responseHeaders = [{ name: 'X-Ipfs-Path', value: '/ipfs/QmbfimSwTuCvGL8XBr3yk1iCjqgk2co2n21cWmcQohymDd' }] @@ -255,6 +257,7 @@ describe('modifyRequest processing', function () { dnslinkResolver.readDnslinkFromTxtRecord = sinon.stub().withArgs(fqdn).returns('/ipfs/QmbfimSwTuCvGL8XBr3yk1iCjqgk2co2n21cWmcQohymDd') // const request = url2request('http://explore.ipld.io/index.html?argTest#hashTest') + request.type = 'sub_frame' // we test a subrequest here because main_frame gets early DNSLink preload in onBeforeRequest expect(modifyRequest.onBeforeRequest(request)).to.equal(undefined) expect(modifyRequest.onHeadersReceived(request)).to.equal(undefined) }) diff --git a/test/functional/lib/options.test.js b/test/functional/lib/options.test.js index 1410924e9..e9d7c4f1b 100644 --- a/test/functional/lib/options.test.js +++ b/test/functional/lib/options.test.js @@ -1,8 +1,9 @@ 'use strict' const { describe, it, beforeEach, after } = require('mocha') +const { expect } = require('chai') const sinon = require('sinon') const browser = require('sinon-chrome') -const { storeMissingOptions, optionDefaults } = require('../../../add-on/src/lib/options') +const { storeMissingOptions, optionDefaults, hostTextToArray, hostArrayToText } = require('../../../add-on/src/lib/options') describe('storeMissingOptions()', function () { beforeEach(() => { @@ -66,3 +67,19 @@ describe('storeMissingOptions()', function () { browser.flush() }) }) + +describe('hostTextToArray()', function () { + it('should sort, dedup hostnames, drop non-FQDNs and produce an array', () => { + const text = `zombo.com\n two.com \n totally not a FQDN \none.pl \nTWO.com\n\n` + const array = ['one.pl', 'two.com', 'zombo.com'] + expect(hostTextToArray(text)).to.be.an('array').to.have.ordered.members(array) + }) +}) + +describe('hostArrayToText()', function () { + it('should sort, deduplicate, drop non-FQDNs and produce multiline string', () => { + const array = ['zombo.com ', 'two.com ', 'ONE.pl ', 'one.pl', 'totall not a FQDN', 'zombo.com'] + const text = `one.pl\ntwo.com\nzombo.com` + expect(hostArrayToText(array)).to.be.a('string').equal(text) + }) +}) diff --git a/yarn.lock b/yarn.lock index 2a9d13dbd..285a985ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -649,6 +649,71 @@ "@cliqz-oss/firefox-client" "0.3.1" es6-promise "^2.0.1" +"@material/animation@^0.41.0": + version "0.41.0" + resolved "https://registry.yarnpkg.com/@material/animation/-/animation-0.41.0.tgz#315b45b32e1aeebee8a4cf555b8ad52076d09ddd" + integrity sha512-yYAwJbX3Q2AFd4dr6IYOsWLQy2HN8zWOFVl9AbUXunjzTfJCa/ecfXCriaT6qkmoNoHeTdJHRrsQJZC5GsPvzA== + +"@material/base@^0.41.0": + version "0.41.0" + resolved "https://registry.yarnpkg.com/@material/base/-/base-0.41.0.tgz#badadce711b4c25b1eb889a5e7581e32cd07c421" + integrity sha512-tEyzwBRu3d1H120SfKsDVYZHcqT5lKohh/7cWKR93aAaPDkSvjpKJIjyu2yuSkjpDduVZGzVocYbOvhUKhhzXQ== + +"@material/elevation@^0.44.1": + version "0.44.1" + resolved "https://registry.yarnpkg.com/@material/elevation/-/elevation-0.44.1.tgz#19efa293e195b00141711899e3c3fde6a3670454" + integrity sha512-Gr2x/FHysM4ty7sctLPT48pw7YItLZvhXsB4ZzXpdhuy7QshxllBW9NGlAMffzrmNu16MTl3ouGzbhArUYz4jw== + dependencies: + "@material/animation" "^0.41.0" + "@material/feature-targeting" "^0.44.1" + "@material/theme" "^0.43.0" + +"@material/feature-targeting@^0.44.1": + version "0.44.1" + resolved "https://registry.yarnpkg.com/@material/feature-targeting/-/feature-targeting-0.44.1.tgz#afafc80294e5efab94bee31a187273d43d34979a" + integrity sha512-90cc7njn4aHbH9UxY8qgZth1W5JgOgcEdWdubH1t7sFkwqFxS5g3zgxSBt46TygFBVIXNZNq35Xmg80wgqO7Pg== + +"@material/ripple@^0.44.1": + version "0.44.1" + resolved "https://registry.yarnpkg.com/@material/ripple/-/ripple-0.44.1.tgz#79cb2ddf1f998498d877d3e3c46b50fed6f13b01" + integrity sha512-prJ1p3bR+GvwAtJgtdeIixsnRVApN3bizGnX7upKoqxsqbBDHj84JxaO8EsG9bjruG/LJu8Fb6WKKdIp2oXHTA== + dependencies: + "@material/animation" "^0.41.0" + "@material/base" "^0.41.0" + "@material/feature-targeting" "^0.44.1" + "@material/theme" "^0.43.0" + +"@material/rtl@^0.42.0": + version "0.42.0" + resolved "https://registry.yarnpkg.com/@material/rtl/-/rtl-0.42.0.tgz#1836e78186c2d8b996f6fbf97adab203535335bc" + integrity sha512-VrnrKJzhmspsN8WXHuxxBZ69yM5IwhCUqWr1t1eNfw3ZEvEj7i1g3P31HGowKThIN1dc1Wh4LE14rCISWCtv5w== + +"@material/selection-control@^0.44.1": + version "0.44.1" + resolved "https://registry.yarnpkg.com/@material/selection-control/-/selection-control-0.44.1.tgz#77a47354a4c5128fa34e3ba98d9cc26e8a92839a" + integrity sha512-Xf1ee2ZV2XJ+rK8OcOD1DZOihfU0uVRdY6iYX/Bqi8k8RXnAbLIBoh6zG3xSwjRNODNvAyHEQaS/ozEfH8eehg== + dependencies: + "@material/ripple" "^0.44.1" + +"@material/switch@0.44.1": + version "0.44.1" + resolved "https://registry.yarnpkg.com/@material/switch/-/switch-0.44.1.tgz#b3a1d6169956a70376ec7a09ef5db26bb090c400" + integrity sha512-BQ91OGtb0ohcszAjVSBjCAnkfJxQrz3r8c5kyofS+qDjYengCe8DaUpcB0PAFAUk5yl7VXs46JholugnIo+IYA== + dependencies: + "@material/animation" "^0.41.0" + "@material/base" "^0.41.0" + "@material/elevation" "^0.44.1" + "@material/feature-targeting" "^0.44.1" + "@material/ripple" "^0.44.1" + "@material/rtl" "^0.42.0" + "@material/selection-control" "^0.44.1" + "@material/theme" "^0.43.0" + +"@material/theme@^0.43.0": + version "0.43.0" + resolved "https://registry.yarnpkg.com/@material/theme/-/theme-0.43.0.tgz#6d9fa113c82e841817882172c152d60d2d203ca6" + integrity sha512-/zndZL6EihI18v2mYd4O8xvOBAAXmLeHyHVK28LozSAaJ9okQgD25wq5Ktk95oMTmPIC+rH66KcK6371ivNk8g== + "@nodeutils/defaults-deep@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@nodeutils/defaults-deep/-/defaults-deep-1.1.0.tgz#bb1124dc8d7ce0bc5da1d668ace58149258ef20b" @@ -3350,11 +3415,6 @@ dicer@^0.2.5: readable-stream "1.1.x" streamsearch "0.1.2" -diff@1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/diff/-/diff-1.0.7.tgz#24bbb001c4a7d5522169e7cabdb2c2814ed91cf4" - integrity sha1-JLuwAcSn1VIhaefKvbLCgU7ZHPQ= - diff@3.5.0, diff@^3.1.0, diff@^3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" @@ -6669,6 +6729,11 @@ is-finite@^1.0.0: dependencies: number-is-nan "^1.0.0" +is-fqdn@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-fqdn/-/is-fqdn-1.0.1.tgz#f3ed9cd5a20238449ae510e10d81258dafca9b70" + integrity sha1-8+2c1aICOESa5RDhDYElja/Km3A= + is-fullwidth-code-point@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" @@ -8666,17 +8731,7 @@ mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1: dependencies: minimist "0.0.8" -mocha-jenkins-reporter@0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/mocha-jenkins-reporter/-/mocha-jenkins-reporter-0.4.1.tgz#d944ce5f7fb157f4bbcea8de8d535db50ade2823" - integrity sha512-IqnIylrkKJG0lxeoawRkhv/uiYojMEw3o9TQOpDFarPYKVq4ymngVPwsyfMB0XMDqtDbOTOCviFg8xOLHb80/Q== - dependencies: - diff "1.0.7" - mkdirp "0.5.1" - mocha "^5.2.0" - xml "^1.0.1" - -mocha@5.2.0, mocha@^5.2.0: +mocha@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/mocha/-/mocha-5.2.0.tgz#6d8ae508f59167f940f2b5b3c4a612ae50c90ae6" integrity sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ== @@ -13888,11 +13943,6 @@ xml2js@^0.4.17, xml2js@~0.4.4: sax ">=0.6.0" xmlbuilder "~9.0.1" -xml@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/xml/-/xml-1.0.1.tgz#78ba72020029c5bc87b8a81a3cfcd74b4a2fc1e5" - integrity sha1-eLpyAgApxbyHuKgaPPzXS0ovweU= - xmlbuilder@~9.0.1: version "9.0.7" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d"