diff --git a/add-on/src/lib/dnslink.js b/add-on/src/lib/dnslink.js index 6d0604139..79a8ee558 100644 --- a/add-on/src/lib/dnslink.js +++ b/add-on/src/lib/dnslink.js @@ -61,7 +61,7 @@ module.exports = function createDnslinkResolver (getState) { // to load the correct path from IPFS // - https://github.com/ipfs/ipfs-companion/issues/298 const ipnsPath = dnslinkResolver.convertToIpnsPath(url) - const gateway = state.ipfsNodeType === 'embedded' ? state.pubGwURLString : state.gwURLString + const gateway = state.localGwAvailable ? state.gwURLString : state.pubGwURLString return pathAtHttpGateway(ipnsPath, gateway) } }, @@ -110,7 +110,7 @@ module.exports = function createDnslinkResolver (getState) { preloadUrlCache.set(url, true) const dnslink = await dnslinkResolver.resolve(url) if (!dnslink) return - if (state.ipfsNodeType === 'embedded') return + if (!state.localGwAvailable) return if (state.peerCount < 1) return return preloadQueue.add(async () => { const { pathname } = new URL(url) diff --git a/add-on/src/lib/http-proxy.js b/add-on/src/lib/http-proxy.js index 068552358..381b12c1c 100644 --- a/add-on/src/lib/http-proxy.js +++ b/add-on/src/lib/http-proxy.js @@ -23,20 +23,31 @@ log.error = debug('ipfs-companion:http-proxy:error') // registerSubdomainProxy is necessary wourkaround for supporting subdomains // under 'localhost' (*.ipfs.localhost) because some operating systems do not // resolve them to local IP and return NX error not found instead -async function registerSubdomainProxy (getState, runtime) { - const { useSubdomainProxy: enable, gwURLString } = getState() +async function registerSubdomainProxy (getState, runtime, notify) { + try { + const { useSubdomainProxy: enable, gwURLString } = getState() - // HTTP Proxy feature is exposed on the gateway port - // Just ensure we use localhost IP to remove any dependency on DNS - const proxy = safeURL(gwURLString, { useLocalhostName: false }) + // HTTP Proxy feature is exposed on the gateway port + // Just ensure we use localhost IP to remove any dependency on DNS + const proxy = safeURL(gwURLString, { useLocalhostName: false }) - // Firefox uses own APIs for selective proxying - if (runtime.isFirefox) { - return registerSubdomainProxyFirefox(enable, proxy.hostname, proxy.port) - } + // Firefox uses own APIs for selective proxying + if (runtime.isFirefox) { + return await registerSubdomainProxyFirefox(enable, proxy.hostname, proxy.port) + } - // at this point we asume Chromium - return registerSubdomainProxyChromium(enable, proxy.host) + // at this point we asume Chromium + return await registerSubdomainProxyChromium(enable, proxy.host) + } catch (err) { + // registerSubdomainProxy is just a failsafe, not necessary in most cases, + // so we should not break init when it fails. + // For now we just log error and exit as NOOP + log.error('registerSubdomainProxy failed', err) + // Show pop-up only the first time, during init() when notify is passed + try { + if (notify) notify('notify_addonIssueTitle', 'notify_addonIssueMsg') + } catch (_) {} + } } // storing listener for later diff --git a/add-on/src/lib/ipfs-companion.js b/add-on/src/lib/ipfs-companion.js index 3af0f001a..89fb6119a 100644 --- a/add-on/src/lib/ipfs-companion.js +++ b/add-on/src/lib/ipfs-companion.js @@ -84,8 +84,8 @@ module.exports = async function init () { ipfsProxyContentScript = await registerIpfsProxyContentScript() log('register all listeners') registerListeners() - await registerSubdomainProxy(getState, runtime) await setApiStatusUpdateInterval(options.ipfsApiPollMs) + await registerSubdomainProxy(getState, runtime, notify) log('init done') await showPendingLandingPages() } catch (error) { @@ -324,7 +324,7 @@ module.exports = async function init () { } ipfsImportHandler.copyShareLink(result) ipfsImportHandler.preloadFilesAtPublicGateway(result) - if (state.ipfsNodeType === 'embedded' || !state.openViaWebUI) { + if (!state.localGwAvailable || !state.openViaWebUI) { return ipfsImportHandler.openFilesAtGateway({ result, openRootInNewTab: true }) } else { return ipfsImportHandler.openFilesAtWebUI(importDir) @@ -557,7 +557,7 @@ module.exports = async function init () { // enable/disable gw redirect based on API going online or offline // newPeerCount === -1 currently implies node is offline. // TODO: use `node.isOnline()` if available (js-ipfs) - if (state.automaticMode && state.ipfsNodeType !== 'embedded') { + if (state.automaticMode && state.localGwAvailable) { if (oldPeerCount === offlinePeerCount && newPeerCount > offlinePeerCount && !state.redirect) { browser.storage.local.set({ useCustomGateway: true }) .then(() => notify('notify_apiOnlineTitle', 'notify_apiOnlineAutomaticModeMsg')) diff --git a/add-on/src/lib/ipfs-request.js b/add-on/src/lib/ipfs-request.js index 28ff0d39c..f524afc1d 100644 --- a/add-on/src/lib/ipfs-request.js +++ b/add-on/src/lib/ipfs-request.js @@ -96,8 +96,9 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru } const postNormalizationSkip = (state, request) => { - // skip requests to the public gateway if embedded node is running (otherwise we have too much recursion) - if (state.ipfsNodeType === 'embedded' && sameGateway(request.url, state.pubGwURL)) { + // skip requests to the public gateway if we can't reedirect them to local + // node is running (otherwise we have too much recursion) + if (!state.localGwAvailable && sameGateway(request.url, state.pubGwURL)) { ignore(request.requestId) // TODO: do not skip and redirect to `ipfs://` and `ipns://` if hasNativeProtocolHandler === true } @@ -351,7 +352,9 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru // All the following requests will be upgraded to IPNS const cachedDnslink = dnslinkResolver.readAndCacheDnslink(new URL(request.url).hostname) const redirectUrl = dnslinkResolver.dnslinkAtGateway(request.url, cachedDnslink) - if (redirectUrl) { + // redirect only if local node is around, as we can't guarantee DNSLink support + // at a public subdomain gateway (requires more than 1 level of wildcard TLS certs) + if (redirectUrl && state.localGwAvailable) { log(`onHeadersReceived: dnslinkRedirect from ${request.url} to ${redirectUrl}`) return { redirectUrl } } @@ -371,8 +374,8 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru const url = new URL(request.url) const pathWithArgs = `${xIpfsPath}${url.search}${url.hash}` const newUrl = pathAtHttpGateway(pathWithArgs, state.pubGwURLString) - // redirect only if anything changed - if (newUrl !== request.url) { + // redirect only if local node is around + if (newUrl && state.localGwAvailable) { log(`onHeadersReceived: normalized ${request.url} to ${newUrl}`) return redirectToGateway(request, newUrl, state, ipfsPathValidator) } @@ -407,9 +410,10 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru if (isRecoverableViaEthDNS(request, state)) { const url = new URL(request.url) url.hostname = `${url.hostname}.link` - const redirect = { redirectUrl: url.toString() } - log(`onErrorOccurred: attempting to recover from DNS error (${request.error}) using EthDNS for ${request.url} → ${redirect.redirectUrl}`, request) - return createTabWithURL(redirect, browser, recoveredTabs) + const redirectUrl = url.toString() + log(`onErrorOccurred: attempting to recover from DNS error (${request.error}) using EthDNS for ${request.url} → ${redirectUrl}`, request) + // TODO: update existing tab + return createTabWithURL(request, redirectUrl, browser, recoveredTabs) } // Check if error can be recovered via DNSLink @@ -419,7 +423,7 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru if (dnslink) { const redirectUrl = dnslinkResolver.dnslinkAtGateway(request.url, dnslink) log(`onErrorOccurred: attempting to recover from network error (${request.error}) using dnslink for ${request.url} → ${redirectUrl}`, request) - return createTabWithURL({ redirectUrl }, browser, recoveredTabs) + return createTabWithURL(request, redirectUrl, browser, recoveredTabs) } } @@ -433,7 +437,7 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru if (isRecoverable(request, state, ipfsPathValidator)) { const redirectUrl = ipfsPathValidator.resolveToPublicUrl(request.url) log(`onErrorOccurred: attempting to recover from network error (${request.error}) for ${request.url} → ${redirectUrl}`, request) - return createTabWithURL({ redirectUrl }, browser, recoveredTabs) + return createTabWithURL(request, redirectUrl, browser, recoveredTabs) } }, @@ -463,7 +467,7 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru if (isRecoverable(request, state, ipfsPathValidator)) { const redirectUrl = ipfsPathValidator.resolveToPublicUrl(request.url) log(`onCompleted: attempting to recover from HTTP Error ${request.statusCode} for ${request.url} → ${redirectUrl}`, request) - return createTabWithURL({ redirectUrl }, browser, recoveredTabs) + return createTabWithURL(request, redirectUrl, browser, recoveredTabs) } } } @@ -474,11 +478,8 @@ exports.createRequestModifier = createRequestModifier function redirectToGateway (request, url, state, ipfsPathValidator) { const { resolveToPublicUrl, resolveToLocalUrl } = ipfsPathValidator - const useLocal = state.ipfsNodeType !== 'embedded' - const redirectUrl = useLocal ? resolveToLocalUrl(url) : resolveToPublicUrl(url) + const redirectUrl = state.localGwAvailable ? resolveToLocalUrl(url) : resolveToPublicUrl(url) // redirect only if we actually change anything - console.log('request.url', request.url) - console.log('redirectUrl', redirectUrl) if (redirectUrl && request.url !== redirectUrl) return { redirectUrl } } @@ -588,11 +589,17 @@ function findHeaderIndex (name, headers) { // Recovery check for onErrorOccurred (request.error) and onCompleted (request.statusCode) function isRecoverable (request, state, ipfsPathValidator) { - return state.recoverFailedHttpRequests && + // Note: we are unable to recover default public gateways without a local one + const { error, statusCode, url } = request + const { redirect, localGwAvailable, pubGwURL, pubSubdomainGwURL } = state + return (state.recoverFailedHttpRequests && request.type === 'main_frame' && - (recoverableNetworkErrors.has(request.error) || recoverableHttpError(request.statusCode)) && - (ipfsPathValidator.publicIpfsOrIpnsResource(request.url) || isIPFS.subdomain(request.url)) && - !sameGateway(request.url, state.pubGwURL) && !sameGateway(request.url, state.pubSubdomainGwURL) + (recoverableNetworkErrors.has(error) || + recoverableHttpError(statusCode)) && + ipfsPathValidator.publicIpfsOrIpnsResource(url) && + ((redirect && localGwAvailable) || + (!sameGateway(url, pubGwURL) && + !sameGateway(url, pubSubdomainGwURL)))) } // Recovery check for onErrorOccurred (request.error) @@ -616,8 +623,11 @@ function isRecoverableViaEthDNS (request, state) { // We can't redirect in onErrorOccurred/onCompleted // Indead, we recover by opening URL in a new tab that replaces the failed one // TODO: display an user-friendly prompt when the very first recovery is done -async function createTabWithURL (redirect, browser, recoveredTabs) { - const tabKey = redirect.redirectUrl +async function createTabWithURL (request, redirectUrl, browser, recoveredTabs) { + // Do nothing if the URL remains the same + if (request.url === redirectUrl) return + + const tabKey = redirectUrl // reuse existing tab, if exists // (this avoids duplicated tabs - https://github.com/ipfs-shipyard/ipfs-companion/issues/805) try { @@ -635,7 +645,7 @@ async function createTabWithURL (redirect, browser, recoveredTabs) { const newTab = await browser.tabs.create({ active: true, openerTabId, - url: redirect.redirectUrl + url: redirectUrl }) if (newTab) recoveredTabs.set(tabKey, newTab.id) } diff --git a/add-on/src/lib/state.js b/add-on/src/lib/state.js index d24c13770..48523a76b 100644 --- a/add-on/src/lib/state.js +++ b/add-on/src/lib/state.js @@ -56,8 +56,12 @@ function initState (options, overrides) { return false } } - state.isNodeConnected = () => state.peerCount > 0 - state.isNodeActive = () => state.peerCount > offlinePeerCount + // TODO state.connected ~= state.peerCount > 0 + // TODO state.nodeActive ~= API is online,eg. state.peerCount > offlinePeerCount + Object.defineProperty(state, 'localGwAvailable', { + // TODO: make quick fetch to confirm it works? + get: function () { return this.ipfsNodeType !== 'embedded' } + }) // apply optional overrides if (overrides) Object.assign(state, overrides) return state diff --git a/test/functional/lib/dnslink.test.js b/test/functional/lib/dnslink.test.js index 6ff255b6d..a06ebe19c 100644 --- a/test/functional/lib/dnslink.test.js +++ b/test/functional/lib/dnslink.test.js @@ -63,8 +63,8 @@ describe('dnslinkResolver (dnslinkPolicy=detectIpfsPathHeader)', function () { dnslinkPolicy: 'detectIpfsPathHeader', peerCount: 1 }) - const getExternalNodeState = () => Object.assign({}, getState(), { ipfsNodeType: 'external' }) - const getEmbeddedNodeState = () => Object.assign({}, getState(), { ipfsNodeType: 'embedded' }) + const getExternalNodeState = () => Object.assign(getState(), { ipfsNodeType: 'external' }) + const getEmbeddedNodeState = () => Object.assign(getState(), { ipfsNodeType: 'embedded' }) describe('dnslinkAtGateway(url)', function () { ['/api/v0/foo', '/ipfs/foo', '/ipns/foo'].forEach(path => { @@ -149,8 +149,8 @@ describe('dnslinkResolver (dnslinkPolicy=enabled)', function () { dnslinkPolicy: 'enabled', peerCount: 1 }) - const getExternalNodeState = () => Object.assign({}, getState(), { ipfsNodeType: 'external' }) - const getEmbeddedNodeState = () => Object.assign({}, getState(), { ipfsNodeType: 'embedded' }) + const getExternalNodeState = () => Object.assign(getState(), { ipfsNodeType: 'external' }) + const getEmbeddedNodeState = () => Object.assign(getState(), { ipfsNodeType: 'embedded' }) describe('dnslinkAtGateway(url)', function () { ['/api/v0/foo', '/ipfs/foo', '/ipns/foo'].forEach(path => { diff --git a/test/functional/lib/ipfs-request-gateway-recover.test.js b/test/functional/lib/ipfs-request-gateway-recover.test.js index c2737164b..5f680dd7a 100644 --- a/test/functional/lib/ipfs-request-gateway-recover.test.js +++ b/test/functional/lib/ipfs-request-gateway-recover.test.js @@ -61,16 +61,21 @@ describe('requestHandler.onCompleted:', function () { // HTTP-level errors await requestHandler.onCompleted(request) assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called') }) - it('should do nothing if broken request is a non-public IPFS request to 127.0.0.1', async function () { + it('should do nothing if broken request is a local request to 127.0.0.1/ipfs', async function () { const request = urlRequestWithStatus('http://127.0.0.1:8080/ipfs/QmYzZgeWE7r8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500) await requestHandler.onCompleted(request) assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called') }) - it('should do nothing if broken request is a non-public IPFS request to localhost', async function () { + it('should do nothing if broken request is a local request to localhost/ipfs', async function () { const request = urlRequestWithStatus('http://localhost:8080/ipfs/QmYzZgeWE7r8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500) await requestHandler.onCompleted(request) assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called') }) + it('should do nothing if broken request is a local request to *.ipfs.localhost', async function () { + const request = urlRequestWithStatus('http://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhw.ipfs.localhost:8080/', 500) + await requestHandler.onCompleted(request) + assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called') + }) it('should do nothing if broken request is to the default public gateway', async function () { const request = urlRequestWithStatus('https://ipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500) await requestHandler.onCompleted(request) @@ -176,13 +181,14 @@ describe('requestHandler.onErrorOccurred:', function () { // network errors await requestHandler.onErrorOccurred(request) assert.ok(browser.tabs.create.withArgs({ url: 'https://ipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', active: true, openerTabId: 20 }).calledOnce, 'tabs.create should be called with IPFS default public gateway URL') }) - it('should recover from unreachable HTTP server by reopening DNSLink on the public gateway', async function () { + it('should recover from unreachable HTTP server by reopening DNSLink on the active gateway', async function () { state.dnslinkPolicy = 'best-effort' dnslinkResolver.setDnslink('en.wikipedia-on-ipfs.org', '/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco') - const expectedUrl = 'http://127.0.0.1:8080/ipns/en.wikipedia-on-ipfs.org/' + // avoid DNS failures when recovering to local gateweay (if available) + const expectedUrl = 'http://localhost:8080/ipns/en.wikipedia-on-ipfs.org/' const request = urlRequestWithNetworkError('https://en.wikipedia-on-ipfs.org/') await requestHandler.onErrorOccurred(request) - assert.ok(browser.tabs.create.withArgs({ url: expectedUrl, active: true, openerTabId: 20 }).calledOnce, 'tabs.create should be called with ENS resource on local gateway URL') + assert.ok(browser.tabs.create.withArgs({ url: expectedUrl, active: true, openerTabId: 20 }).calledOnce, 'tabs.create should be called with DNSLink on local gateway URL') dnslinkResolver.clearCache() }) it('should recover from failed DNS for .eth opening it on EthDNS gateway at .eth.link', async function () { diff --git a/test/functional/lib/ipfs-request-gateway-redirect.test.js b/test/functional/lib/ipfs-request-gateway-redirect.test.js index 46671d863..0f31f47ad 100644 --- a/test/functional/lib/ipfs-request-gateway-redirect.test.js +++ b/test/functional/lib/ipfs-request-gateway-redirect.test.js @@ -262,8 +262,9 @@ describe('modifyRequest.onBeforeRequest:', function () { describe('with external node', function () { beforeEach(function () { state.ipfsNodeType = 'external' + // dweb.link is the default subdomain gw }) - it('should be redirected to localhost gateway (*.ipfs)', function () { + it('should be redirected to localhost gateway (*.ipfs on default gw)', function () { state.redirect = true const request = url2request(`https://${cid}.ipfs.dweb.link/`) @@ -276,7 +277,14 @@ describe('modifyRequest.onBeforeRequest:', function () { expect(modifyRequest.onBeforeRequest(request).redirectUrl) .to.equal(`http://localhost:8080/ipfs/${cid}/`) }) - it('should be redirected to localhost gateway (*.ipns)', function () { + it('should be redirected to localhost gateway (*.ipfs on 3rd party gw)', function () { + state.redirect = true + const request = url2request(`https://${cid}.ipfs.cf-ipfs.com/`) + request.responseHeaders = [{ name: 'X-Ipfs-Path', value: '/ipfs/QmPhnvn747LqwPYMJmQVorMaGbMSgA7mRRoyyZYz3DoZRQ' }] + expect(modifyRequest.onBeforeRequest(request).redirectUrl) + .to.equal(`http://localhost:8080/ipfs/${cid}/`) + }) + it('should be redirected to localhost gateway (*.ipns on default gw)', function () { state.redirect = true const request = url2request(`https://${peerid}.ipns.dweb.link/`) request.responseHeaders = [{ name: 'X-Ipfs-Path', value: '/ipns/QmPhnvn747LqwPYMJmQVorMaGbMSgA7mRRoyyZYz3DoZRQ' }] @@ -288,14 +296,22 @@ describe('modifyRequest.onBeforeRequest:', function () { describe('with embedded node', function () { beforeEach(function () { state.ipfsNodeType = 'embedded' + // dweb.link is the default subdomain gw }) - it('should be left untouched for dweb.link', function () { + it('should be left untouched for *.ipfs at default public subdomain gw', function () { state.redirect = true const request = url2request(`https://${cid}.ipfs.dweb.link/`) request.responseHeaders = [{ name: 'X-Ipfs-Path', value: '/ipfs/QmPhnvn747LqwPYMJmQVorMaGbMSgA7mRRoyyZYz3DoZRQ' }] expectNoRedirect(modifyRequest, request) }) - it('should be left untouched for IPNS', function () { + it('should be redirected to user-prefered public gateway if 3rd party subdomain gw', function () { + state.redirect = true + const request = url2request(`https://${cid}.ipfs.cf-ipfs.com/`) + request.responseHeaders = [{ name: 'X-Ipfs-Path', value: '/ipfs/QmPhnvn747LqwPYMJmQVorMaGbMSgA7mRRoyyZYz3DoZRQ' }] + expect(modifyRequest.onBeforeRequest(request).redirectUrl) + .to.equal(`https://${cid}.ipfs.dweb.link/`) + }) + it('should be left untouched for *.ipns at default public subdomain gw', function () { state.redirect = true const request = url2request(`https://${peerid}.ipns.dweb.link/`) request.responseHeaders = [{ name: 'X-Ipfs-Path', value: '/ipns/QmPhnvn747LqwPYMJmQVorMaGbMSgA7mRRoyyZYz3DoZRQ' }]