Skip to content

Commit 712926c

Browse files
committed
feat: context actions on DNSLink sites
This adds context actions such as "Copy IPFS path", "Copy CID", "Pin" to DNSLink websites without redirect. It includes refactoring of ipfsPathValidator to expose high level resolvers: - resolveToPublicUrl: always return a meaningful, publicly accessible URL that can be accessed without the need of IPFS client. - resolveToIpfsPath: return a valid IPFS path that can be accessed with IPFS client. - resolveToImmutableIpfsPath: same as resolveToIpfsPath, but the path is always immutable /ipfs/ - resolveToCid: returnis direct CID without anything else
1 parent 9c36d20 commit 712926c

14 files changed

+498
-95
lines changed

add-on/src/lib/copier.js

+7-11
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) {
4443
try {
45-
const ipfs = getIpfs()
4644
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

+2
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

+9-5
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

+108-12
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,32 @@
33

44
const IsIpfs = require('is-ipfs')
55

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

1522
function subdomainToIpfsPath (url) {
1623
if (typeof url === 'string') {
1724
url = new URL(url)
1825
}
1926
const fqdn = url.hostname.split('.')
27+
// TODO: support CID split with commas
2028
const cid = fqdn[0]
29+
// TODO: support .ip(f|n)s. being at deeper levels
2130
const protocol = fqdn[1]
22-
return `/${protocol}/${cid}${url.pathname}`
31+
return `/${protocol}/${cid}${url.pathname}${url.search}${url.hash}`
2332
}
2433

2534
function pathAtHttpGateway (path, gatewayUrl) {
@@ -39,34 +48,38 @@ function trimHashAndSearch (urlString) {
3948
}
4049
exports.trimHashAndSearch = trimHashAndSearch
4150

42-
function createIpfsPathValidator (getState, dnsLink) {
51+
function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) {
4352
const ipfsPathValidator = {
4453
// Test if URL is a Public IPFS resource
4554
// (pass validIpfsOrIpnsUrl(url) and not at the local gateway or API)
4655
publicIpfsOrIpnsResource (url) {
4756
// exclude custom gateway and api, otherwise we have infinite loops
4857
if (!url.startsWith(getState().gwURLString) && !url.startsWith(getState().apiURLString)) {
49-
return validIpfsOrIpnsUrl(url, dnsLink)
58+
return validIpfsOrIpnsUrl(url, dnslinkResolver)
5059
}
5160
return false
5261
},
5362

5463
// Test if URL is a valid IPFS or IPNS
5564
// (IPFS needs to be a CID, IPNS can be PeerId or have dnslink entry)
5665
validIpfsOrIpnsUrl (url) {
57-
return validIpfsOrIpnsUrl(url, dnsLink)
66+
return validIpfsOrIpnsUrl(url, dnslinkResolver)
5867
},
5968

6069
// Same as validIpfsOrIpnsUrl (url) but for paths
6170
// (we have separate methods to avoid 'new URL' where possible)
6271
validIpfsOrIpnsPath (path) {
63-
return validIpfsOrIpnsPath(path, dnsLink)
72+
return validIpfsOrIpnsPath(path, dnslinkResolver)
6473
},
6574

6675
// 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
6876
isIpfsPageActionsContext (url) {
69-
return (IsIpfs.url(url) && !url.startsWith(getState().apiURLString)) || IsIpfs.subdomain(url)
77+
console.log(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,7 +90,89 @@ 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+
const result = await getIpfs().name.resolve(ipnsRoot, { recursive: true, nocache: false })
151+
// Old API returned object, latest one returns string ¯\_(ツ)_/¯
152+
const ipfsRoot = result.Path ? result.Path : result
153+
// Return original path with swapped root (keeps pathname + ?search + #hash)
154+
return path.replace(ipnsRoot, ipfsRoot)
155+
}
156+
// Return /ipfs/ path
157+
return path
158+
},
159+
160+
// TODO: add description and tests
161+
// Resolve URL or path to a raw CID:
162+
// - Result is the direct CID
163+
// - Ignores ?search and #hash from original URL
164+
// - Returns null if no CID can be produced
165+
async resolveToCid (urlOrPath) {
166+
const path = ipfsPathValidator.resolveToIpfsPath(urlOrPath)
167+
// Fail fast if no IPFS Path
168+
if (!path) return null
169+
// Resolve to raw CID
170+
const rawPath = trimHashAndSearch(path)
171+
const result = await getIpfs().resolve(rawPath, { recursive: true, dhtt: '5s', dhtrc: 1 })
172+
const directCid = result.split('/')[2]
173+
return directCid
80174
}
175+
81176
}
82177

83178
return ipfsPathValidator
@@ -122,6 +217,7 @@ function validIpnsPath (path, dnsLink) {
122217
return true
123218
}
124219
// then see if there is an DNSLink entry for 'ipnsRoot' hostname
220+
// TODO: use dnslink cache only
125221
if (dnsLink.readAndCacheDnslink(ipnsRoot)) {
126222
// console.log('==> IPNS for FQDN with valid dnslink: ', ipnsRoot)
127223
return true

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

+7-7
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) {

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

+10-9
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
const browser = require('webextension-polyfill')
55
const IsIpfs = require('is-ipfs')
6-
const { safeIpfsPath, trimHashAndSearch } = require('../../lib/ipfs-path')
6+
const { trimHashAndSearch } = require('../../lib/ipfs-path')
77
const { contextMenuCopyAddressAtPublicGw, contextMenuCopyRawCid, contextMenuCopyCanonicalAddress } = require('../../lib/context-menus')
88

99
// The store contains and mutates the state for the app
@@ -312,15 +312,16 @@ async function getIpfsApi () {
312312
return (bg && bg.ipfsCompanion) ? bg.ipfsCompanion.ipfs : null
313313
}
314314

315+
async function getIpfsPathValidator () {
316+
const bg = await getBackgroundPage()
317+
return (bg && bg.ipfsCompanion) ? bg.ipfsCompanion.ipfsPathValidator : null
318+
}
319+
315320
async function resolveToPinPath (ipfs, url) {
321+
// Prior issues:
316322
// https://github.com/ipfs-shipyard/ipfs-companion/issues/567
317-
url = trimHashAndSearch(url)
318323
// https://github.com/ipfs/ipfs-companion/issues/303
319-
let path = safeIpfsPath(url)
320-
if (/^\/ipns/.test(path)) {
321-
const response = await ipfs.name.resolve(path, { recursive: true, nocache: false })
322-
// old API returned object, latest one returns string ¯\_(ツ)_/¯
323-
return response.Path ? response.Path : response
324-
}
325-
return path
324+
const pathValidator = await getIpfsPathValidator()
325+
const pinPath = trimHashAndSearch(pathValidator.resolveToImmutableIpfsPath(url))
326+
return pinPath
326327
}

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
"request-progress": "3.0.0",
9494
"shx": "0.3.2",
9595
"simple-progress-webpack-plugin": "1.1.2",
96-
"sinon": "7.2.3",
96+
"sinon": "7.2.7",
9797
"sinon-chrome": "2.3.2",
9898
"standard": "12.0.1",
9999
"tar": "4.4.8",

0 commit comments

Comments
 (0)