Skip to content

Commit 3a959b1

Browse files
colinfruitlidel
andcommitted
feat: recover dead sub-domain gateways (#802)
* add subdomainPublicGatewayUrl option with default of https://dweb.link * add broken subdomain recovery via dweb.link public gateway * add tests for subdomain recovery * update gateway-form * use subdomainPattern to extract protocol and cid from subdomain urls * update add-on/_locales/en/messages.json Co-Authored-By: Marcin Rataj <[email protected]>
1 parent f8449f7 commit 3a959b1

File tree

10 files changed

+123
-12
lines changed

10 files changed

+123
-12
lines changed

add-on/_locales/en/messages.json

+8
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,14 @@
291291
"message": "Fallback URL used when Custom Gateway is not available and for copying shareable links",
292292
"description": "An option description on the Preferences screen (option_publicGatewayUrl_description)"
293293
},
294+
"option_publicSubdomainGatewayUrl_title": {
295+
"message": "Default Public Subdomain Gateway",
296+
"description": "An option title on the Preferences screen (option_publicSubdomainGatewayUrl_title)"
297+
},
298+
"option_publicSubdomainGatewayUrl_description": {
299+
"message": "Default public subdomain gateway for recovery of broken subdomain gateways",
300+
"description": "An option description on the Preferences screen (option_publicSubdomainGatewayUrl_description)"
301+
},
294302
"option_header_api": {
295303
"message": "API",
296304
"description": "A section header on the Preferences screen (option_header_api)"

add-on/src/lib/ipfs-companion.js

+4
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,10 @@ module.exports = async function init () {
685685
state.pubGwURL = new URL(change.newValue)
686686
state.pubGwURLString = state.pubGwURL.toString()
687687
break
688+
case 'publicSubdomainGatewayUrl':
689+
state.pubSubdomainGwURL = new URL(change.newValue)
690+
state.pubSubdomainGwURLString = state.pubSubdomainGwURL.toString()
691+
break
688692
case 'useCustomGateway':
689693
state.redirect = change.newValue
690694
break

add-on/src/lib/ipfs-path.js

+33-4
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ function subdomainToIpfsPath (url) {
2424
if (typeof url === 'string') {
2525
url = new URL(url)
2626
}
27-
const fqdn = url.hostname.split('.')
27+
const match = url.toString().match(IsIpfs.subdomainPattern)
28+
if (!match) throw new Error('no match for IsIpfs.subdomainPattern')
29+
2830
// TODO: support CID split with commas
29-
const cid = fqdn[0]
31+
const cid = match[1]
3032
// TODO: support .ip(f|n)s. being at deeper levels
31-
const protocol = fqdn[1]
33+
const protocol = match[2]
3234
return `/${protocol}/${cid}${url.pathname}${url.search}${url.hash}`
3335
}
3436

@@ -38,6 +40,18 @@ function pathAtHttpGateway (path, gatewayUrl) {
3840
}
3941
exports.pathAtHttpGateway = pathAtHttpGateway
4042

43+
function redirectSubdomainGateway (url, subdomainGateway) {
44+
if (typeof url === 'string') {
45+
url = new URL(url)
46+
}
47+
const match = url.toString().match(IsIpfs.subdomainPattern)
48+
if (!match) throw new Error('no match for IsIpfs.subdomainPattern')
49+
const cid = match[1]
50+
const protocol = match[2]
51+
return trimDoubleSlashes(`${subdomainGateway.protocol}//${cid}.${protocol}.${subdomainGateway.hostname}${url.pathname}${url.search}${url.hash}`)
52+
}
53+
exports.redirectSubdomainGateway = redirectSubdomainGateway
54+
4155
function trimDoubleSlashes (urlString) {
4256
return urlString.replace(/([^:]\/)\/+/g, '$1')
4357
}
@@ -72,7 +86,11 @@ function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) {
7286
validIpfsOrIpnsPath (path) {
7387
return validIpfsOrIpnsPath(path, dnslinkResolver)
7488
},
75-
89+
// Test if URL is a subdomain gateway resource
90+
// TODO: add test if URL is a public subdomain resource
91+
ipfsOrIpnsSubdomain (url) {
92+
return IsIpfs.subdomain(url)
93+
},
7694
// Test if actions such as 'copy URL', 'pin/unpin' should be enabled for the URL
7795
isIpfsPageActionsContext (url) {
7896
return Boolean(url && !url.startsWith(getState().apiURLString) && (
@@ -108,6 +126,17 @@ function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) {
108126
// Return original URL (eg. DNSLink domains) or null if not an URL
109127
return input.startsWith('http') ? input : null
110128
},
129+
// Resolve URL or path to subdomain gateway
130+
// - non-subdomain path is returned as-is
131+
// The purpose of this resolver is to return a valid IPFS
132+
// subdomain URL
133+
resolveToPublicSubdomainUrl (url, optionalGatewayUrl) {
134+
// if non-subdomain return as-is
135+
if (!IsIpfs.subdomain(url)) return url
136+
137+
const gateway = optionalGatewayUrl || getState().pubSubdomainGwURL
138+
return redirectSubdomainGateway(url, gateway)
139+
},
111140

112141
// Resolve URL or path to IPFS Path:
113142
// - The path can be /ipfs/ or /ipns/

add-on/src/lib/ipfs-request.js

+17-8
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,13 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
392392
// Check if error can be recovered by opening same content-addresed path
393393
// using active gateway (public or local, depending on redirect state)
394394
if (isRecoverable(request, state, ipfsPathValidator)) {
395-
const redirectUrl = ipfsPathValidator.resolveToPublicUrl(request.url, state.pubGwURLString)
395+
let redirectUrl
396+
// if subdomain request redirect to default public subdomain url
397+
if (ipfsPathValidator.ipfsOrIpnsSubdomain(request.url)) {
398+
redirectUrl = ipfsPathValidator.resolveToPublicSubdomainUrl(request.url, state.pubSubdomainGwURL)
399+
} else {
400+
redirectUrl = ipfsPathValidator.resolveToPublicUrl(request.url, state.pubGwURLString)
401+
}
396402
log(`onErrorOccurred: attempting to recover from network error (${request.error}) for ${request.url}`, redirectUrl)
397403
return createTabWithURL({ redirectUrl }, browser)
398404
}
@@ -404,13 +410,16 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
404410
const state = getState()
405411
if (!state.active) return
406412
if (request.statusCode === 200) return // finish if no error to recover from
413+
let redirectUrl
407414
if (isRecoverable(request, state, ipfsPathValidator)) {
408-
const redirectUrl = ipfsPathValidator.resolveToPublicUrl(request.url, state.pubGwURLString)
409-
const redirect = { redirectUrl }
410-
if (redirect) {
411-
log(`onCompleted: attempting to recover from HTTP Error ${request.statusCode} for ${request.url}`, redirect)
412-
return createTabWithURL(redirect, browser)
415+
// if subdomain request redirect to default public subdomain url
416+
if (ipfsPathValidator.ipfsOrIpnsSubdomain(request.url)) {
417+
redirectUrl = ipfsPathValidator.resolveToPublicSubdomainUrl(request.url, state.pubSubdomainGwURL)
418+
} else {
419+
redirectUrl = ipfsPathValidator.resolveToPublicUrl(request.url, state.pubGwURLString)
413420
}
421+
log(`onCompleted: attempting to recover from HTTP Error ${request.statusCode} for ${request.url}`, redirectUrl)
422+
return createTabWithURL({ redirectUrl }, browser)
414423
}
415424
}
416425
}
@@ -527,8 +536,8 @@ function isRecoverable (request, state, ipfsPathValidator) {
527536
return state.recoverFailedHttpRequests &&
528537
request.type === 'main_frame' &&
529538
(recoverableNetworkErrors.has(request.error) || recoverableHttpError(request.statusCode)) &&
530-
ipfsPathValidator.publicIpfsOrIpnsResource(request.url) &&
531-
!request.url.startsWith(state.pubGwURLString)
539+
(ipfsPathValidator.publicIpfsOrIpnsResource(request.url) || ipfsPathValidator.ipfsOrIpnsSubdomain(request.url)) &&
540+
!request.url.startsWith(state.pubGwURLString) && !request.url.includes(state.pubSubdomainGwURL.hostname)
532541
}
533542

534543
// Recovery check for onErrorOccurred (request.error)

add-on/src/lib/options.js

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ exports.optionDefaults = Object.freeze({
1111
ipfsNodeType: buildDefaultIpfsNodeType(),
1212
ipfsNodeConfig: buildDefaultIpfsNodeConfig(),
1313
publicGatewayUrl: 'https://ipfs.io',
14+
publicSubdomainGatewayUrl: 'https://dweb.link',
1415
useCustomGateway: true,
1516
noRedirectHostnames: [],
1617
automaticMode: true,

add-on/src/lib/state.js

+3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ function initState (options) {
1717
state.pubGwURL = safeURL(options.publicGatewayUrl)
1818
state.pubGwURLString = state.pubGwURL.toString()
1919
delete state.publicGatewayUrl
20+
state.pubSubdomainGwURL = safeURL(options.publicSubdomainGatewayUrl)
21+
state.pubSubdomainGwURLString = state.pubSubdomainGwURL.toString()
22+
delete state.publicSubdomainGatewayUrl
2023
state.redirect = options.useCustomGateway
2124
delete state.useCustomGateway
2225
state.apiURL = safeURL(options.ipfsApiUrl)

add-on/src/options/forms/gateways-form.js

+25
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ function gatewaysForm ({
1616
useCustomGateway,
1717
noRedirectHostnames,
1818
publicGatewayUrl,
19+
publicSubdomainGatewayUrl,
1920
onOptionChange
2021
}) {
2122
const onCustomGatewayUrlChange = onOptionChange('customGatewayUrl', normalizeGatewayURL)
2223
const onUseCustomGatewayChange = onOptionChange('useCustomGateway')
2324
const onPublicGatewayUrlChange = onOptionChange('publicGatewayUrl', normalizeGatewayURL)
25+
const onPublicSubdomainGatewayUrlChange = onOptionChange('publicSubdomainGatewayUrl', normalizeGatewayURL)
2426
const onNoRedirectHostnamesChange = onOptionChange('noRedirectHostnames', hostTextToArray)
2527
const mixedContentWarning = !secureContextUrl.test(customGatewayUrl)
2628
const supportRedirectToCustomGateway = ipfsNodeType !== 'embedded'
@@ -48,6 +50,29 @@ function gatewaysForm ({
4850
onchange=${onPublicGatewayUrlChange}
4951
value=${publicGatewayUrl} />
5052
</div>
53+
<div>
54+
<label for="publicSubdomainGatewayUrl">
55+
<dl>
56+
<dt>${browser.i18n.getMessage('option_publicSubdomainGatewayUrl_title')}</dt>
57+
<dd>
58+
${browser.i18n.getMessage('option_publicSubdomainGatewayUrl_description')}
59+
<p><a href="https://docs.ipfs.io/guides/guides/addressing/#subdomain-gateway" target="_blank">
60+
${browser.i18n.getMessage('option_legend_readMore')}
61+
</a></p>
62+
</dd>
63+
</dl>
64+
</label>
65+
<input
66+
id="publicSubdomainGatewayUrl"
67+
type="url"
68+
inputmode="url"
69+
required
70+
pattern="^https?://[^/]+/?$"
71+
spellcheck="false"
72+
title="Enter URL without any sub-path"
73+
onchange=${onPublicSubdomainGatewayUrlChange}
74+
value=${publicSubdomainGatewayUrl} />
75+
</div>
5176
${supportRedirectToCustomGateway && allowChangeOfCustomGateway ? html`
5277
<div>
5378
<label for="customGatewayUrl">

add-on/src/options/page.js

+1
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ module.exports = function optionsPage (state, emit) {
6666
customGatewayUrl: state.options.customGatewayUrl,
6767
useCustomGateway: state.options.useCustomGateway,
6868
publicGatewayUrl: state.options.publicGatewayUrl,
69+
publicSubdomainGatewayUrl: state.options.publicSubdomainGatewayUrl,
6970
noRedirectHostnames: state.options.noRedirectHostnames,
7071
onOptionChange
7172
})}

add-on/src/popup/browser-action/store.js

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ module.exports = (state, emitter) => {
2323
isIpfsOnline: false,
2424
ipfsApiUrl: null,
2525
publicGatewayUrl: null,
26+
publicSubdomainGatewayUrl: null,
2627
gatewayAddress: null,
2728
swarmPeers: null,
2829
gatewayVersion: null,

test/functional/lib/ipfs-request-gateway-recover.test.js

+30
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ describe('requestHandler.onCompleted:', function () { // HTTP-level errors
4646
state.recoverFailedHttpRequests = true
4747
state.dnslinkPolicy = false
4848
})
49+
it('should do nothing if broken request is for the default subdomain gateway', async function () {
50+
const request = urlRequestWithStatus('https://QmYzZgeWE7r8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h.ipfs.dweb.link/wiki/', 500)
51+
await requestHandler.onCompleted(request)
52+
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
53+
})
54+
it('should redirect to default subdomain gateway on broken subdomain gateway request', async function () {
55+
const request = urlRequestWithStatus('http://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq.ipfs.brokenexample.com/wiki/', 500)
56+
await requestHandler.onCompleted(request)
57+
assert.ok(browser.tabs.create.withArgs({ url: 'https://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq.ipfs.dweb.link/wiki/', active: true, openerTabId: 20 }).calledOnce, 'tabs.create should be called with default subdomain gateway URL')
58+
})
4959
it('should do nothing if broken request is a non-IPFS request', async function () {
5060
const request = urlRequestWithStatus('https://wikipedia.org', 500)
5161
await requestHandler.onCompleted(request)
@@ -78,6 +88,11 @@ describe('requestHandler.onCompleted:', function () { // HTTP-level errors
7888
state.recoverFailedHttpRequests = false
7989
state.dnslinkPolicy = false
8090
})
91+
it('should do nothing on failed subdomain gateway request', async function () {
92+
const request = urlRequestWithStatus('https://QmYzZgeWE7r8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h.ipfs.brokendomain.com/wiki/', 500)
93+
await requestHandler.onCompleted(request)
94+
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
95+
})
8196
it('should do nothing on broken non-default public gateway IPFS request', async function () {
8297
const request = urlRequestWithStatus('https://nondefaultipfs.io/ipfs/QmYbZgeWE7y8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h', 500)
8398
await requestHandler.onCompleted(request)
@@ -120,6 +135,16 @@ describe('requestHandler.onErrorOccurred:', function () { // network errors
120135
state.recoverFailedHttpRequests = true
121136
state.dnslinkPolicy = false
122137
})
138+
it('should do nothing if failed request is for the default subdomain gateway', async function () {
139+
const request = urlRequestWithStatus('https://QmYzZgeWE7r8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h.ipfs.dweb.link/wiki/', 500)
140+
await requestHandler.onErrorOccurred(request)
141+
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
142+
})
143+
it('should redirect to default subdomain gateway on failed subdomain gateway request', async function () {
144+
const request = urlRequestWithStatus('http://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq.ipfs.brokenexample.com/wiki/', 500)
145+
await requestHandler.onErrorOccurred(request)
146+
assert.ok(browser.tabs.create.withArgs({ url: 'https://bafybeiemxf5abjwjbikoz4mc3a3dla6ual3jsgpdr4cjr3oz3evfyavhwq.ipfs.dweb.link/wiki/', active: true, openerTabId: 20 }).calledOnce, 'tabs.create should be called with default subdomain gateway URL')
147+
})
123148
it('should do nothing if failed request is a non-IPFS request', async function () {
124149
const request = urlRequestWithNetworkError('https://wikipedia.org')
125150
await requestHandler.onErrorOccurred(request)
@@ -178,6 +203,11 @@ describe('requestHandler.onErrorOccurred:', function () { // network errors
178203
await requestHandler.onErrorOccurred(request)
179204
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
180205
})
206+
it('should do nothing on failed subdomain gateway request', async function () {
207+
const request = urlRequestWithStatus('https://QmYzZgeWE7r8HXkH8zbb8J9ddHQvp8LTqm6isL791eo14h.ipfs.brokendomain.com/wiki/', 500)
208+
await requestHandler.onErrorOccurred(request)
209+
assert.ok(browser.tabs.create.notCalled, 'tabs.create should not be called')
210+
})
181211
it('should do nothing on unreachable HTTP server with DNSLink', async function () {
182212
state.dnslinkPolicy = 'best-effort'
183213
dnslinkResolver.setDnslink('en.wikipedia-on-ipfs.org', '/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco')

0 commit comments

Comments
 (0)