Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: recover from failed HTTP requests to third party gateways #783

Merged
merged 9 commits into from
Oct 17, 2019
8 changes: 8 additions & 0 deletions add-on/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,14 @@
"message": "Check before HTTP request",
"description": "A select field option description on the Preferences screen (option_dnslinkPolicy_enabled)"
},
"option_recoverFailedHttpRequests_title": {
"message": "Recover Failed HTTP Requests",
"description": "An option title on the Preferences screen (option_recoverFailedHttpRequests_title)"
},
"option_recoverFailedHttpRequests_description": {
"message": "Recover failed HTTP requests for IPFS resources by redirecting to the public gateway.",
"description": "An option description on the Preferences screen (option_recoverFailedHttpRequests_description)"
},
"option_detectIpfsPathHeader_title": {
"message": "Detect X-Ipfs-Path Header",
"description": "An option title on the Preferences screen (option_detectIpfsPathHeader_title)"
Expand Down
8 changes: 8 additions & 0 deletions add-on/src/lib/ipfs-companion.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ module.exports = async function init () {
browser.webRequest.onBeforeRequest.addListener(onBeforeRequest, { urls: ['<all_urls>'] }, ['blocking'])
browser.webRequest.onHeadersReceived.addListener(onHeadersReceived, { urls: ['<all_urls>'] }, ['blocking', 'responseHeaders'])
browser.webRequest.onErrorOccurred.addListener(onErrorOccurred, { urls: ['<all_urls>'] })
browser.webRequest.onCompleted.addListener(onCompleted, { urls: ['<all_urls>'] })
browser.storage.onChanged.addListener(onStorageChange)
browser.webNavigation.onCommitted.addListener(onNavigationCommitted)
browser.webNavigation.onDOMContentLoaded.addListener(onDOMContentLoaded)
Expand Down Expand Up @@ -169,6 +170,10 @@ module.exports = async function init () {
return modifyRequest.onErrorOccurred(request)
}

function onCompleted (request) {
return modifyRequest.onCompleted(request)
}

// RUNTIME MESSAGES (one-off messaging)
// ===================================================================
// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/sendMessage
Expand Down Expand Up @@ -670,6 +675,9 @@ module.exports = async function init () {
await browser.storage.local.set({ detectIpfsPathHeader: true })
}
break
case 'recoverFailedHttpRequests':
state[key] = change.newValue
break
case 'logNamespaces':
shouldReloadExtension = true
state[key] = localStorage.debug = change.newValue
Expand Down
83 changes: 70 additions & 13 deletions add-on/src/lib/ipfs-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@ const recoverableErrors = new Set([
'net::ERR_INTERNET_DISCONNECTED' // no network
])

const recoverableErrorCodes = new Set([
404,
408,
410,
415,
451,
500,
502,
503,
504,
509,
520,
521,
522,
523,
524,
525,
526
])

// Request modifier provides event listeners for the various stages of making an HTTP request
// API Details: https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/webRequest
function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, runtime) {
Expand Down Expand Up @@ -380,29 +400,48 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
// console.log('onErrorOccurred:' + request.error)
// console.log('onErrorOccurred', request)
// Check if error is final and can be recovered via DNSLink
let redirect
const recoverableViaDnslink =
state.dnslinkPolicy &&
request.type === 'main_frame' &&
recoverableErrors.has(request.error)
if (recoverableViaDnslink && dnslinkResolver.canLookupURL(request.url)) {
// Explicit call to ignore global DNSLink policy and force DNS TXT lookup
const cachedDnslink = dnslinkResolver.readAndCacheDnslink(new URL(request.url).hostname)
const dnslinkRedirect = dnslinkResolver.dnslinkRedirect(request.url, cachedDnslink)
// We can't redirect in onErrorOccurred, so if DNSLink is present
// recover by opening IPNS version in a new tab
// TODO: add tests and demo
if (dnslinkRedirect) {
log(`onErrorOccurred: recovering using dnslink for ${request.url}`, dnslinkRedirect)
const currentTabId = await browser.tabs.query({ active: true, currentWindow: true }).then(tabs => tabs[0].id)
await browser.tabs.create({
active: true,
openerTabId: currentTabId,
url: dnslinkRedirect.redirectUrl
})
redirect = dnslinkResolver.dnslinkRedirect(request.url, cachedDnslink)
log(`onErrorOccurred: attempting to recover using dnslink for ${request.url}`, redirect)
}
// if error cannot be recovered via DNSLink
// direct the request to the public gateway
const recoverable = isRecoverable(request, state, ipfsPathValidator)
if (!redirect && recoverable) {
const redirectUrl = ipfsPathValidator.resolveToPublicUrl(request.url, state.pubGwURLString)
redirect = { redirectUrl }
log(`onErrorOccurred: attempting to recover failed request for ${request.url}`, redirect)
}
// We can't redirect in onErrorOccurred, so if DNSLink is present
// recover by opening IPNS version in a new tab
// TODO: add tests and demo
if (redirect) {
createTabWithURL(redirect, browser)
}
},

async onCompleted (request) {
const state = getState()

const recoverable =
isRecoverable(request, state, ipfsPathValidator) &&
recoverableErrorCodes.has(request.statusCode)
if (recoverable) {
const redirectUrl = ipfsPathValidator.resolveToPublicUrl(request.url, state.pubGwURLString)
const redirect = { redirectUrl }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we drop redirect and use redirectUrl directly?

Copy link
Contributor Author

@colinfruit colinfruit Oct 17, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this is the cleanest way to do this. Without it we would have to worry about the dnslink redirect, and do something like const redirectUrl = redirect ? redirect.redirectUrl : null to be able to use this with the same createTabWithURL function.

if (redirect) {
log(`onCompleted: attempting to recover failed request for ${request.url}`, redirect)
createTabWithURL(redirect, browser)
}
}
}

}
}

Expand Down Expand Up @@ -508,3 +547,21 @@ function normalizedUnhandledIpfsProtocol (request, pubGwUrl) {
function findHeaderIndex (name, headers) {
return headers.findIndex(x => x.name && x.name.toLowerCase() === name.toLowerCase())
}

// utility functions for handling redirects
// from onErrorOccurred and onCompleted
function isRecoverable (request, state, ipfsPathValidator) {
return state.recoverFailedHttpRequests &&
ipfsPathValidator.publicIpfsOrIpnsResource(request.url) &&
!request.url.startsWith(state.pubGwURLString) &&
request.type === 'main_frame'
}

async function createTabWithURL (redirect, browser) {
const currentTabId = await browser.tabs.query({ active: true, currentWindow: true }).then(tabs => tabs[0].id)
await browser.tabs.create({
active: true,
openerTabId: currentTabId,
url: redirect.redirectUrl
})
}
1 change: 1 addition & 0 deletions add-on/src/lib/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ exports.optionDefaults = Object.freeze({
automaticMode: true,
linkify: false,
dnslinkPolicy: 'best-effort',
recoverFailedHttpRequests: true,
detectIpfsPathHeader: true,
preloadAtPublicGateway: true,
catchUnhandledProtocols: true,
Expand Down
11 changes: 11 additions & 0 deletions add-on/src/options/forms/experiments-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function experimentsForm ({
catchUnhandledProtocols,
linkify,
dnslinkPolicy,
recoverFailedHttpRequests,
detectIpfsPathHeader,
ipfsProxy,
logNamespaces,
Expand All @@ -22,6 +23,7 @@ function experimentsForm ({
const onCatchUnhandledProtocolsChange = onOptionChange('catchUnhandledProtocols')
const onLinkifyChange = onOptionChange('linkify')
const onDnslinkPolicyChange = onOptionChange('dnslinkPolicy')
const onrecoverFailedHttpRequestsChange = onOptionChange('recoverFailedHttpRequests')
const onDetectIpfsPathHeaderChange = onOptionChange('detectIpfsPathHeader')
const onIpfsProxyChange = onOptionChange('ipfsProxy')

Expand Down Expand Up @@ -96,6 +98,15 @@ function experimentsForm ({
</option>
</select>
</div>
<div>
<label for="recoverFailedHttpRequests">
<dl>
<dt>${browser.i18n.getMessage('option_recoverFailedHttpRequests_title')}</dt>
<dd>${browser.i18n.getMessage('option_recoverFailedHttpRequests_description')}</dd>
</dl>
</label>
<div>${switchToggle({ id: 'recoverFailedHttpRequests', checked: recoverFailedHttpRequests, onchange: onrecoverFailedHttpRequestsChange })}</div>
</div>
<div>
<label for="detectIpfsPathHeader">
<dl>
Expand Down
1 change: 1 addition & 0 deletions add-on/src/options/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ module.exports = function optionsPage (state, emit) {
catchUnhandledProtocols: state.options.catchUnhandledProtocols,
linkify: state.options.linkify,
dnslinkPolicy: state.options.dnslinkPolicy,
recoverFailedHttpRequests: state.options.recoverFailedHttpRequests,
detectIpfsPathHeader: state.options.detectIpfsPathHeader,
ipfsProxy: state.options.ipfsProxy,
logNamespaces: state.options.logNamespaces,
Expand Down
163 changes: 163 additions & 0 deletions test/functional/lib/ipfs-request-gateway-recover.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
'use strict'
const { describe, it, before, beforeEach, after, afterEach } = require('mocha')
const sinon = require('sinon')
const { assert } = require('chai')
const { URL } = require('url')
const browser = require('sinon-chrome')
const { initState } = require('../../../add-on/src/lib/state')
const { createRuntimeChecks } = require('../../../add-on/src/lib/runtime-checks')
const { createRequestModifier } = require('../../../add-on/src/lib/ipfs-request')
const createDnslinkResolver = require('../../../add-on/src/lib/dnslink')
const { createIpfsPathValidator } = require('../../../add-on/src/lib/ipfs-path')
const { optionDefaults } = require('../../../add-on/src/lib/options')

const url2request = (url, type = 'main_frame') => {
return { url, type }
}
const urlRequestWithStatus = (url, statusCode = 200, type = 'main_frame') => {
return { ...url2request(url, type), statusCode }
}

describe('requestHandler.onCompleted:', function () {
let state, dnslinkResolver, ipfsPathValidator, requestHandler, runtime

before(function () {
global.URL = URL
browser.tabs = { ...browser.tabs, query: sinon.stub().resolves([{ id: 20 }]) }
global.browser = browser
})

beforeEach(async function () {
state = initState(optionDefaults)
const getState = () => state
const getIpfs = () => {}
dnslinkResolver = createDnslinkResolver(getState)
runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests
ipfsPathValidator = createIpfsPathValidator(getState, getIpfs, dnslinkResolver)
requestHandler = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime)
})

describe('with recoverFailedHttpRequests=true', function () {
beforeEach(function () {
state.recoverFailedHttpRequests = true
})
it('should do nothing if broken request is a non-IPFS request', async function () {
const request = urlRequestWithStatus('https://wikipedia.org', 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', 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 to the default public gateway', async function () {
const request = urlRequestWithStatus('https://ipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 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 not a \'main_frame\' request', async function () {
const request = urlRequestWithStatus('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500, 'stylesheet')
await requestHandler.onCompleted(request)
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
})
it('should redirect broken non-default public gateway IPFS request to public gateway', async function () {
const request = urlRequestWithStatus('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500)
await requestHandler.onCompleted(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')
})
})

describe('with recoverFailedHttpRequests=false', function () {
beforeEach(function () {
state.recoverFailedHttpRequests = false
})
it('should do nothing on broken non-default public gateway IPFS request', async function () {
const request = urlRequestWithStatus('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500)
await requestHandler.onCompleted(request)
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
})
})

afterEach(function () {
browser.tabs.create.reset()
})

after(function () {
delete global.url
delete global.browser
browser.flush()
})
})

describe('requestHandler.onErrorOccurred:', function () {
let state, dnslinkResolver, ipfsPathValidator, requestHandler, runtime

before(function () {
global.URL = URL
browser.tabs = { ...browser.tabs, query: sinon.stub().resolves([{ id: 20 }]) }
Copy link
Contributor Author

@colinfruit colinfruit Oct 14, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise this query will throw an error.

global.browser = browser
})

beforeEach(async function () {
state = initState(optionDefaults)
const getState = () => state
const getIpfs = () => {}
dnslinkResolver = createDnslinkResolver(getState)
runtime = Object.assign({}, await createRuntimeChecks(browser)) // make it mutable for tests
ipfsPathValidator = createIpfsPathValidator(getState, getIpfs, dnslinkResolver)
requestHandler = createRequestModifier(getState, dnslinkResolver, ipfsPathValidator, runtime)
})

describe('with recoverFailedHttpRequests=true', function () {
beforeEach(function () {
state.recoverFailedHttpRequests = true
})
it('should do nothing if failed request is a non-IPFS request', async function () {
const request = url2request('https://wikipedia.org', 500)
await requestHandler.onErrorOccurred(request)
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
})
it('should do nothing if failed request is a non-public IPFS request', async function () {
const request = url2request('http://127.0.0.1:8080/ipfs/QmYzZgeWE7r8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500)
await requestHandler.onErrorOccurred(request)
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
})
it('should do nothing if failed request is to the default public gateway', async function () {
const request = url2request('https://ipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500)
await requestHandler.onErrorOccurred(request)
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
})
it('should do nothing if failed request is not a \'main_frame\' request', async function () {
const request = url2request('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 'stylesheet')
await requestHandler.onErrorOccurred(request)
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
})
it('should redirect failed non-default public gateway IPFS request to public gateway', async function () {
const request = url2request('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h')
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')
})
})

describe('with recoverFailedHttpRequests=false', function () {
beforeEach(function () {
state.recoverFailedHttpRequests = false
})
it('should do nothing on failed non-default public gateway IPFS request', async function () {
const request = url2request('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h')
await requestHandler.onErrorOccurred(request)
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
})
})

afterEach(function () {
browser.tabs.create.reset()
})

after(function () {
delete global.url
delete global.browser
browser.flush()
})
})