Skip to content

Commit 6e46e87

Browse files
authored
Merge pull request #694 from ipfs-shipyard/feat/actions-on-dnslink-sites
When we introduced option to opt-out from redirect per site (#687), it came with a side effect of removing IPFS context actions. This PR (aka Show IPFS Actions on DNSLink Sites): - Adds context actions on DNSLink sites (when redirect is disabled) - Adds a bunch of tests - Tweaks behavior of pin/unpin via browser action menu - Works around missing dnslink resolver under js
2 parents ff71012 + 3e89ac8 commit 6e46e87

14 files changed

+590
-100
lines changed

add-on/src/lib/copier.js

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
'use strict'
22

3-
const { safeIpfsPath, trimHashAndSearch } = require('./ipfs-path')
43
const { findValueForContext } = require('./context-menus')
54

65
async function copyTextToClipboard (text, notify) {
@@ -32,21 +31,19 @@ async function copyTextToClipboard (text, notify) {
3231
}
3332
}
3433

35-
function createCopier (getState, getIpfs, notify) {
34+
function createCopier (notify, ipfsPathValidator) {
3635
return {
3736
async copyCanonicalAddress (context, contextType) {
3837
const url = await findValueForContext(context, contextType)
39-
const rawIpfsAddress = safeIpfsPath(url)
40-
await copyTextToClipboard(rawIpfsAddress, notify)
38+
const ipfsPath = ipfsPathValidator.resolveToIpfsPath(url)
39+
await copyTextToClipboard(ipfsPath, notify)
4140
},
4241

4342
async copyRawCid (context, contextType) {
43+
const url = await findValueForContext(context, contextType)
4444
try {
45-
const ipfs = getIpfs()
46-
const url = await findValueForContext(context, contextType)
47-
const rawIpfsAddress = trimHashAndSearch(safeIpfsPath(url))
48-
const directCid = (await ipfs.resolve(rawIpfsAddress, { recursive: true, dhtt: '5s', dhtrc: 1 })).split('/')[2]
49-
await copyTextToClipboard(directCid, notify)
45+
const cid = await ipfsPathValidator.resolveToCid(url)
46+
await copyTextToClipboard(cid, notify)
5047
} catch (error) {
5148
console.error('Unable to resolve/copy direct CID:', error.message)
5249
if (notify) {
@@ -65,9 +62,8 @@ function createCopier (getState, getIpfs, notify) {
6562

6663
async copyAddressAtPublicGw (context, contextType) {
6764
const url = await findValueForContext(context, contextType)
68-
const state = getState()
69-
const urlAtPubGw = url.replace(state.gwURLString, state.pubGwURLString)
70-
await copyTextToClipboard(urlAtPubGw, notify)
65+
const publicUrl = ipfsPathValidator.resolveToPublicUrl(url)
66+
await copyTextToClipboard(publicUrl, notify)
7167
}
7268
}
7369
}

add-on/src/lib/dnslink.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ const PQueue = require('p-queue')
77
const { offlinePeerCount } = require('./state')
88
const { pathAtHttpGateway } = require('./ipfs-path')
99

10+
// TODO: add Preferences toggle to disable redirect of DNSLink websites (while keeping async dnslink lookup)
11+
1012
module.exports = function createDnslinkResolver (getState) {
1113
// DNSLink lookup result cache
1214
const cacheOptions = { max: 1000, maxAge: 1000 * 60 * 60 * 12 }

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const browser = require('webextension-polyfill')
55
const toMultiaddr = require('uri-to-multiaddr')
66
const { optionDefaults, storeMissingOptions, migrateOptions } = require('./options')
77
const { initState, offlinePeerCount } = require('./state')
8-
const { createIpfsPathValidator, pathAtHttpGateway } = require('./ipfs-path')
8+
const { createIpfsPathValidator } = require('./ipfs-path')
99
const createDnslinkResolver = require('./dnslink')
1010
const { createRequestModifier, redirectOptOutHint } = require('./ipfs-request')
1111
const { initIpfsClient, destroyIpfsClient } = require('./ipfs-client')
@@ -56,9 +56,9 @@ module.exports = async function init () {
5656
}
5757
}
5858

59-
copier = createCopier(getState, getIpfs, notify)
6059
dnslinkResolver = createDnslinkResolver(getState)
61-
ipfsPathValidator = createIpfsPathValidator(getState, dnslinkResolver)
60+
ipfsPathValidator = createIpfsPathValidator(getState, getIpfs, dnslinkResolver)
61+
copier = createCopier(notify, ipfsPathValidator)
6262
contextMenus = createContextMenus(getState, runtime, ipfsPathValidator, {
6363
onAddFromContext,
6464
onCopyCanonicalAddress: copier.copyCanonicalAddress,
@@ -174,7 +174,7 @@ module.exports = async function init () {
174174
// console.log((sender.tab ? 'Message from a content script:' + sender.tab.url : 'Message from the extension'), request)
175175
if (request.pubGwUrlForIpfsOrIpnsPath) {
176176
const path = request.pubGwUrlForIpfsOrIpnsPath
177-
const result = ipfsPathValidator.validIpfsOrIpnsPath(path) ? pathAtHttpGateway(path, state.pubGwURLString) : null
177+
const result = ipfsPathValidator.validIpfsOrIpnsPath(path) ? ipfsPathValidator.resolveToPublicUrl(path, state.pubGwURLString) : null
178178
return Promise.resolve({ pubGwUrlForIpfsOrIpnsPath: result })
179179
}
180180
}
@@ -257,7 +257,7 @@ module.exports = async function init () {
257257
return new Promise((resolve, reject) => {
258258
const http = new XMLHttpRequest()
259259
// Make sure preload request is excluded from global redirect
260-
const preloadUrl = pathAtHttpGateway(`${path}#${redirectOptOutHint}`, state.pubGwURLString)
260+
const preloadUrl = ipfsPathValidator.resolveToPublicUrl(`${path}#${redirectOptOutHint}`, state.pubGwURLString)
261261
http.open('HEAD', preloadUrl)
262262
http.onreadystatechange = function () {
263263
if (this.readyState === this.DONE) {
@@ -699,6 +699,10 @@ module.exports = async function init () {
699699
return dnslinkResolver
700700
},
701701

702+
get ipfsPathValidator () {
703+
return ipfsPathValidator
704+
},
705+
702706
get notify () {
703707
return notify
704708
},

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

Lines changed: 161 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,34 @@
22
/* eslint-env browser */
33

44
const IsIpfs = require('is-ipfs')
5+
const isFQDN = require('is-fqdn')
56

6-
function safeIpfsPath (urlOrPath) {
7+
function normalizedIpfsPath (urlOrPath) {
8+
let result = urlOrPath
9+
// Convert CID-in-subdomain URL to /ipns/<fqdn>/ path
710
if (IsIpfs.subdomain(urlOrPath)) {
8-
urlOrPath = subdomainToIpfsPath(urlOrPath)
11+
result = subdomainToIpfsPath(urlOrPath)
912
}
10-
// better safe than sorry: https://github.com/ipfs/ipfs-companion/issues/303
11-
return decodeURIComponent(urlOrPath.replace(/^.*(\/ip(f|n)s\/.+)$/, '$1'))
13+
// Drop everything before the IPFS path
14+
result = result.replace(/^.*(\/ip(f|n)s\/.+)$/, '$1')
15+
// Remove Unescape special characters
16+
// https://github.com/ipfs/ipfs-companion/issues/303
17+
result = decodeURIComponent(result)
18+
// Return a valid IPFS path or null otherwise
19+
return IsIpfs.path(result) ? result : null
1220
}
13-
exports.safeIpfsPath = safeIpfsPath
21+
exports.normalizedIpfsPath = normalizedIpfsPath
1422

1523
function subdomainToIpfsPath (url) {
1624
if (typeof url === 'string') {
1725
url = new URL(url)
1826
}
1927
const fqdn = url.hostname.split('.')
28+
// TODO: support CID split with commas
2029
const cid = fqdn[0]
30+
// TODO: support .ip(f|n)s. being at deeper levels
2131
const protocol = fqdn[1]
22-
return `/${protocol}/${cid}${url.pathname}`
32+
return `/${protocol}/${cid}${url.pathname}${url.search}${url.hash}`
2333
}
2434

2535
function pathAtHttpGateway (path, gatewayUrl) {
@@ -39,34 +49,37 @@ function trimHashAndSearch (urlString) {
3949
}
4050
exports.trimHashAndSearch = trimHashAndSearch
4151

42-
function createIpfsPathValidator (getState, dnsLink) {
52+
function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) {
4353
const ipfsPathValidator = {
4454
// Test if URL is a Public IPFS resource
4555
// (pass validIpfsOrIpnsUrl(url) and not at the local gateway or API)
4656
publicIpfsOrIpnsResource (url) {
4757
// exclude custom gateway and api, otherwise we have infinite loops
4858
if (!url.startsWith(getState().gwURLString) && !url.startsWith(getState().apiURLString)) {
49-
return validIpfsOrIpnsUrl(url, dnsLink)
59+
return validIpfsOrIpnsUrl(url, dnslinkResolver)
5060
}
5161
return false
5262
},
5363

5464
// Test if URL is a valid IPFS or IPNS
5565
// (IPFS needs to be a CID, IPNS can be PeerId or have dnslink entry)
5666
validIpfsOrIpnsUrl (url) {
57-
return validIpfsOrIpnsUrl(url, dnsLink)
67+
return validIpfsOrIpnsUrl(url, dnslinkResolver)
5868
},
5969

6070
// Same as validIpfsOrIpnsUrl (url) but for paths
6171
// (we have separate methods to avoid 'new URL' where possible)
6272
validIpfsOrIpnsPath (path) {
63-
return validIpfsOrIpnsPath(path, dnsLink)
73+
return validIpfsOrIpnsPath(path, dnslinkResolver)
6474
},
6575

6676
// Test if actions such as 'copy URL', 'pin/unpin' should be enabled for the URL
67-
// TODO: include hostname check for DNSLink and display option to copy CID even if no redirect
6877
isIpfsPageActionsContext (url) {
69-
return (IsIpfs.url(url) && !url.startsWith(getState().apiURLString)) || IsIpfs.subdomain(url)
78+
return Boolean(url && !url.startsWith(getState().apiURLString) && (
79+
IsIpfs.url(url) ||
80+
IsIpfs.subdomain(url) ||
81+
dnslinkResolver.cachedDnslink(new URL(url).hostname)
82+
))
7083
},
7184

7285
// Test if actions such as 'per site redirect toggle' should be enabled for the URL
@@ -77,12 +90,146 @@ function createIpfsPathValidator (getState, dnsLink) {
7790
(url.startsWith('http') && // hide on non-HTTP pages
7891
!url.startsWith(state.gwURLString) && // hide on /ipfs/*
7992
!url.startsWith(state.apiURLString))) // hide on api port
93+
},
94+
95+
// Resolve URL or path to HTTP URL:
96+
// - IPFS paths are attached to HTTP Gateway root
97+
// - URL of DNSLinked websites are returned as-is
98+
// The purpose of this resolver is to always return a meaningful, publicly
99+
// accessible URL that can be accessed without the need of IPFS client.
100+
resolveToPublicUrl (urlOrPath, optionalGatewayUrl) {
101+
const input = urlOrPath
102+
// CID-in-subdomain is good as-is
103+
if (IsIpfs.subdomain(input)) return input
104+
// IPFS Paths should be attached to the public gateway
105+
const ipfsPath = normalizedIpfsPath(input)
106+
const gateway = optionalGatewayUrl || getState().pubGwURLString
107+
if (ipfsPath) return pathAtHttpGateway(ipfsPath, gateway)
108+
// Return original URL (eg. DNSLink domains) or null if not an URL
109+
return input.startsWith('http') ? input : null
110+
},
111+
112+
// Resolve URL or path to IPFS Path:
113+
// - The path can be /ipfs/ or /ipns/
114+
// - Keeps pathname + ?search + #hash from original URL
115+
// - Returns null if no valid path can be produced
116+
// The purpose of this resolver is to return a valid IPFS path
117+
// that can be accessed with IPFS client.
118+
resolveToIpfsPath (urlOrPath) {
119+
const input = urlOrPath
120+
// Try to normalize to IPFS path (gateway path or CID-in-subdomain)
121+
const ipfsPath = normalizedIpfsPath(input)
122+
if (ipfsPath) return ipfsPath
123+
// Check URL for DNSLink
124+
if (!input.startsWith('http')) return null
125+
const { hostname } = new URL(input)
126+
const dnslink = dnslinkResolver.cachedDnslink(hostname)
127+
if (dnslink) {
128+
// Return full IPNS path (keeps pathname + ?search + #hash)
129+
return dnslinkResolver.convertToIpnsPath(input)
130+
}
131+
// No IPFS path by this point
132+
return null
133+
},
134+
135+
// Resolve URL or path to Immutable IPFS Path:
136+
// - Same as resolveToIpfsPath, but the path is always immutable /ipfs/
137+
// - Keeps pathname + ?search + #hash from original URL
138+
// - Returns null if no valid path can be produced
139+
// The purpose of this resolver is to return immutable /ipfs/ address
140+
// even if /ipns/ is present in its input.
141+
async resolveToImmutableIpfsPath (urlOrPath) {
142+
const path = ipfsPathValidator.resolveToIpfsPath(urlOrPath)
143+
// Fail fast if no IPFS Path
144+
if (!path) return null
145+
// Resolve /ipns/ → /ipfs/
146+
if (IsIpfs.ipnsPath(path)) {
147+
const labels = path.split('/')
148+
// We resolve /ipns/<fqdn> as value in DNSLink cache may be out of date
149+
const ipnsRoot = `/ipns/${labels[2]}`
150+
151+
// js-ipfs v0.34 does not support DNSLinks in ipfs.name.resolve: https://github.com/ipfs/js-ipfs/issues/1918
152+
// TODO: remove ipfsNameResolveWithDnslinkFallback when js-ipfs implements DNSLink support in ipfs.name.resolve
153+
const ipfsNameResolveWithDnslinkFallback = async (resolve) => {
154+
try {
155+
return await resolve()
156+
} catch (err) {
157+
const fqdn = ipnsRoot.replace(/^.*\/ipns\/([^/]+).*/, '$1')
158+
if (err.message === 'Non-base58 character' && isFQDN(fqdn)) {
159+
// js-ipfs without dnslink support, fallback to the value read from DNSLink
160+
const dnslink = dnslinkResolver.readAndCacheDnslink(fqdn)
161+
if (dnslink) {
162+
// swap problematic /ipns/{fqdn} with /ipfs/{cid} and retry lookup
163+
const safePath = trimDoubleSlashes(ipnsRoot.replace(/^.*(\/ipns\/[^/]+)/, dnslink))
164+
if (ipnsRoot !== safePath) {
165+
return ipfsPathValidator.resolveToImmutableIpfsPath(safePath)
166+
}
167+
}
168+
}
169+
throw err
170+
}
171+
}
172+
const result = await ipfsNameResolveWithDnslinkFallback(async () =>
173+
// dhtt/dhtrc optimize for lookup time
174+
getIpfs().name.resolve(ipnsRoot, { recursive: true, dhtt: '5s', dhtrc: 1 })
175+
)
176+
177+
// Old API returned object, latest one returns string ¯\_(ツ)_/¯
178+
const ipfsRoot = result.Path ? result.Path : result
179+
// Return original path with swapped root (keeps pathname + ?search + #hash)
180+
return path.replace(ipnsRoot, ipfsRoot)
181+
}
182+
// Return /ipfs/ path
183+
return path
184+
},
185+
186+
// Resolve URL or path to a raw CID:
187+
// - Result is the direct CID
188+
// - Ignores ?search and #hash from original URL
189+
// - Returns null if no CID can be produced
190+
// The purpose of this resolver is to return direct CID without anything else.
191+
async resolveToCid (urlOrPath) {
192+
const path = ipfsPathValidator.resolveToIpfsPath(urlOrPath)
193+
// Fail fast if no IPFS Path
194+
if (!path) return null
195+
// Drop unused parts
196+
const rawPath = trimHashAndSearch(path)
197+
198+
// js-ipfs v0.34 does not support DNSLinks in ipfs.resolve: https://github.com/ipfs/js-ipfs/issues/1918
199+
// TODO: remove ipfsResolveWithDnslinkFallback when js-ipfs implements DNSLink support in ipfs.resolve
200+
const ipfsResolveWithDnslinkFallback = async (resolve) => {
201+
try {
202+
return await resolve()
203+
} catch (err) {
204+
const fqdn = rawPath.replace(/^.*\/ipns\/([^/]+).*/, '$1')
205+
if (err.message === 'resolve non-IPFS names is not implemented' && isFQDN(fqdn)) {
206+
// js-ipfs without dnslink support, fallback to the value read from DNSLink
207+
const dnslink = dnslinkResolver.readAndCacheDnslink(fqdn)
208+
if (dnslink) {
209+
// swap problematic /ipns/{fqdn} with /ipfs/{cid} and retry lookup
210+
const safePath = trimDoubleSlashes(rawPath.replace(/^.*(\/ipns\/[^/]+)/, dnslink))
211+
if (rawPath !== safePath) {
212+
const result = await ipfsPathValidator.resolveToCid(safePath)
213+
// return in format of ipfs.resolve()
214+
return IsIpfs.cid(result) ? `/ipfs/${result}` : result
215+
}
216+
}
217+
}
218+
throw err
219+
}
220+
}
221+
const result = await ipfsResolveWithDnslinkFallback(async () =>
222+
// dhtt/dhtrc optimize for lookup time
223+
getIpfs().resolve(rawPath, { recursive: true, dhtt: '5s', dhtrc: 1 })
224+
)
225+
226+
const directCid = IsIpfs.ipfsPath(result) ? result.split('/')[2] : result
227+
return directCid
80228
}
81229
}
82230

83231
return ipfsPathValidator
84232
}
85-
86233
exports.createIpfsPathValidator = createIpfsPathValidator
87234

88235
function validIpfsOrIpnsUrl (url, dnsLink) {
@@ -122,6 +269,7 @@ function validIpnsPath (path, dnsLink) {
122269
return true
123270
}
124271
// then see if there is an DNSLink entry for 'ipnsRoot' hostname
272+
// TODO: use dnslink cache only
125273
if (dnsLink.readAndCacheDnslink(ipnsRoot)) {
126274
// console.log('==> IPNS for FQDN with valid dnslink: ', ipnsRoot)
127275
return true

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
const LRU = require('lru-cache')
55
const IsIpfs = require('is-ipfs')
6-
const { safeIpfsPath, pathAtHttpGateway } = require('./ipfs-path')
6+
const { pathAtHttpGateway } = require('./ipfs-path')
77
const redirectOptOutHint = 'x-ipfs-companion-no-redirect'
88
const recoverableErrors = new Set([
99
// Firefox
@@ -127,7 +127,7 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
127127
}
128128
// Detect valid /ipfs/ and /ipns/ on any site
129129
if (ipfsPathValidator.publicIpfsOrIpnsResource(request.url) && isSafeToRedirect(request, runtime)) {
130-
return redirectToGateway(request.url, state, dnslinkResolver)
130+
return redirectToGateway(request.url, state, ipfsPathValidator)
131131
}
132132
// Detect dnslink using heuristics enabled in Preferences
133133
if (state.dnslinkPolicy && dnslinkResolver.canLookupURL(request.url)) {
@@ -321,7 +321,7 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
321321
return dnslinkRedirect
322322
}
323323
}
324-
return redirectToGateway(request.url, state, dnslinkResolver)
324+
return redirectToGateway(request.url, state, ipfsPathValidator)
325325
}
326326

327327
// Detect X-Ipfs-Path Header and upgrade transport to IPFS:
@@ -368,7 +368,7 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
368368
// redirect only if anything changed
369369
if (newUrl !== request.url) {
370370
console.log(`[ipfs-companion] onHeadersReceived: normalized ${request.url} to ${newUrl}`)
371-
return redirectToGateway(newUrl, state, dnslinkResolver)
371+
return redirectToGateway(newUrl, state, ipfsPathValidator)
372372
}
373373
}
374374
}
@@ -426,11 +426,11 @@ exports.redirectOptOutHint = redirectOptOutHint
426426
exports.createRequestModifier = createRequestModifier
427427
exports.onHeadersReceivedRedirect = onHeadersReceivedRedirect
428428

429-
function redirectToGateway (requestUrl, state, dnslinkResolver) {
429+
function redirectToGateway (requestUrl, state, ipfsPathValidator) {
430430
// TODO: redirect to `ipfs://` if hasNativeProtocolHandler === true
431431
const gateway = state.ipfsNodeType === 'embedded' ? state.pubGwURLString : state.gwURLString
432-
const path = safeIpfsPath(requestUrl)
433-
return { redirectUrl: pathAtHttpGateway(path, gateway) }
432+
const redirectUrl = ipfsPathValidator.resolveToPublicUrl(requestUrl, gateway)
433+
return { redirectUrl }
434434
}
435435

436436
function isSafeToRedirect (request, runtime) {

0 commit comments

Comments
 (0)