Skip to content

Commit

Permalink
feat: recover from failed HTTP requests to third party gateways (#783)
Browse files Browse the repository at this point in the history
This closes #640, enabling dead public gateways to be redirected
to the default public gateway.  This includes DNSLink websites that went offline.

Added as an experimental option enabled by default.
  • Loading branch information
colinfruit authored and lidel committed Oct 17, 2019
1 parent 8512691 commit 614da95
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 13 deletions.
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 @@ -106,6 +106,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 @@ -170,6 +171,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 @@ -693,6 +698,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 }
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 }]) }
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()
})
})

0 comments on commit 614da95

Please sign in to comment.