Skip to content

Commit

Permalink
feat: linkify only officially supported custom protocols
Browse files Browse the repository at this point in the history
- Support `dweb:` address scheme: closes #280
- Disabled support for unsupported schemes, as described in
  #283 (comment)
- Improve performance on complex and dynamic pages: closes #231
  • Loading branch information
lidel committed Sep 27, 2017
1 parent afe1f5e commit 44d1811
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 67 deletions.
32 changes: 26 additions & 6 deletions add-on/src/lib/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ function registerListeners () {
browser.webRequest.onBeforeRequest.addListener(onBeforeRequest, {urls: ['<all_urls>']}, ['blocking'])
browser.storage.onChanged.addListener(onStorageChange)
browser.tabs.onUpdated.addListener(onUpdatedTab)
browser.runtime.onMessage.addListener(onRuntimeMessage)
browser.runtime.onConnect.addListener(onRuntimeConnect)
}

// REDIRECT
Expand Down Expand Up @@ -258,7 +260,18 @@ function readDnslinkTxtRecordFromApi (fqdn) {
})
}

// PORTS
// RUNTIME MESSAGES (one-off messaging)
// ===================================================================
// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/sendMessage

function onRuntimeMessage (request, sender) {
// console.log((sender.tab ? 'Message from a content script:' + sender.tab.url : 'Message from the extension'), request)
if (request.isIpfsPath) {
return Promise.resolve({isIpfsPath: window.IsIpfs.path(request.isIpfsPath)})
}
}

// PORTS (connection-based messaging)
// ===================================================================
// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/runtime/connect
// Make a connection between different contexts inside the add-on,
Expand All @@ -268,15 +281,15 @@ function readDnslinkTxtRecordFromApi (fqdn) {
const browserActionPortName = 'browser-action-port'
var browserActionPort

browser.runtime.onConnect.addListener(port => {
function onRuntimeConnect (port) {
// console.log('onConnect', port)
if (port.name === browserActionPortName) {
browserActionPort = port
browserActionPort.onMessage.addListener(handleMessageFromBrowserAction)
browserActionPort.onDisconnect.addListener(() => { browserActionPort = null })
sendStatusUpdateToBrowserAction()
}
})
}

function handleMessageFromBrowserAction (message) {
// console.log('In background script, received message from browser action', message)
Expand Down Expand Up @@ -407,15 +420,22 @@ function isIpfsPageActionsContext (url) {
async function onUpdatedTab (tabId, changeInfo, tab) {
if (tab && tab.url) {
if (state.linkify && changeInfo.status === 'complete') {
console.log(`Running linkfyDOM for ${tab.url}`)
console.log(`[ipfs-companion] Running linkfyDOM for ${tab.url}`)
try {
await browser.tabs.executeScript(tabId, {
file: '/src/lib/npm/browser-polyfill.min.js',
matchAboutBlank: false,
allFrames: true,
runAt: 'document_start'
})
await browser.tabs.executeScript(tabId, {
file: '/src/lib/linkifyDOM.js',
matchAboutBlank: false,
allFrames: true
allFrames: true,
runAt: 'document_idle'
})
} catch (error) {
console.error(`Unable to linkify DOM at '${tab.url}' due to ${error}`)
console.error(`Unable to linkify DOM at '${tab.url}' due to`, error)
}
}
}
Expand Down
168 changes: 108 additions & 60 deletions add-on/src/lib/linkifyDOM.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* plain text with IPFS addresses with clickable links.
* Loosely based on https://github.com/mdn/webextensions-examples/blob/master/emoji-substitution/substitute.js
* Note that this is a quick&dirty PoC and may slow down browsing experience.
* Test page: http://bit.ly/2fgkF4E
* TODO: measure & improve performance
*/

Expand All @@ -14,10 +15,19 @@
return
}

// Limit contentType to "text/plain" or "text/html"
if (document.contentType !== undefined && document.contentType !== 'text/plain' && document.contentType !== 'text/html') {
return
}

// linkify lock
window.ipfsLinkifiedDOM = true
window.ipfsLinkifyValidationCache = new Map()

const urlRE = /(?:\s+|^)(\/ip(?:f|n)s\/|dweb:\/ip(?:f|n)s\/|ipns:\/\/|ipfs:\/\/)([^\s+"<>]+)/g

const urlRE = /(?:\s+|^)(?:\/ip(f|n)s\/|fs:|ipns:|ipfs:)[^\s+"<>]+/g
// Chrome compatibility
// var browser = browser || chrome

// tags we will scan looking for un-hyperlinked IPFS addresses
const allowedParents = [
Expand All @@ -28,117 +38,155 @@
's', 'strong', 'sub', 'sup', 'td', 'th', 'tt', 'u', 'var'
]

const textNodeXpath = '//text()[(parent::' + allowedParents.join(' or parent::') + ') and ' +
"(contains(., 'ipfs') or contains(., 'ipns')) ]"
const textNodeXpath = './/text()[' +
"(contains(., '/ipfs/') or contains(., '/ipns/') or contains(., 'ipns:/') or contains(., 'ipfs:/')) and " +
'not(ancestor::a) and not(ancestor::script) and not(ancestor::style) and ' +
'(parent::' + allowedParents.join(' or parent::') + ') ' +
']'

linkifyContainer(document.body)
function init () {
linkifyContainer(document.body)

// body.appendChild(document.createTextNode('fooo /ipfs/QmTAsnXoWmLZQEpvyZscrReFzqxP3pvULfGVgpJuayrp1w bar'))
new MutationObserver(function (mutations) {
for (let mutation of mutations) {
if (mutation.type === 'childList') {
for (let addedNode of mutation.addedNodes) {
if (addedNode.nodeType === Node.TEXT_NODE) {
linkifyTextNode(addedNode)
} else {
linkifyContainer(addedNode)
// body.appendChild(document.createTextNode('fooo /ipfs/QmTAsnXoWmLZQEpvyZscrReFzqxP3pvULfGVgpJuayrp1w bar'))
new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.type === 'childList') {
for (let addedNode of mutation.addedNodes) {
if (addedNode.nodeType === Node.TEXT_NODE) {
setTimeout(() => linkifyTextNode(addedNode), 0)
} else {
setTimeout(() => linkifyContainer(addedNode), 0)
}
}
}
}
if (mutation.type === 'characterData') {
linkifyTextNode(mutation.target)
}
}
}).observe(document.body, {
characterData: true,
childList: true,
subtree: true
})
if (mutation.type === 'characterData') {
setTimeout(() => linkifyTextNode(mutation.target), 0)
}
})
}).observe(document.body, {
characterData: true,
childList: true,
subtree: true
})
}

function linkifyContainer (container) {
// console.log('linkifyContainer', container)
if (!container.nodeType) {
if (!container || !container.nodeType) {
return
}
if (container.className && container.className.match(/\blinkifiedIpfsAddress\b/)) {
// prevent infinite recursion
return
}
const xpathResult = document.evaluate(textNodeXpath, container, null, XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, null)
const xpathResult = document.evaluate(textNodeXpath, container, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null)
let i = 0
function continuation () {
async function continuation () {
let node = null
let counter = 0
while ((node = xpathResult.snapshotItem(i++))) {
const parent = node.parentNode
if (!parent) continue
// Skip if no longer in visible DOM
if (!parent || !document.body.contains(node)) continue
// Skip already linkified nodes
if (parent.className && parent.className.match(/\blinkifiedIpfsAddress\b/)) continue
// Skip styled <pre> -- often highlighted by script.
if (parent.tagName === 'PRE' && parent.className) continue
// Skip forms, textareas
if (parent.isContentEditable) continue
linkifyTextNode(node)
if (++counter > 50) {
return setTimeout(continuation, 0)
await linkifyTextNode(node)
if (++counter > 10) {
return setTimeout(continuation, 100)
}
}
}
setTimeout(continuation, 0)
window.requestAnimationFrame(continuation)
}

function normalizeHref (href) {
// console.log(href)
// convert various variants to regular URL at the public gateway
if (href.startsWith('ipfs:')) {
href = href.replace('ipfs:', '/ipfs/')
function textToIpfsResource (match) {
let root = match[1]
let path = match[2]

// skip trailing dots and commas
path = path.replace(/[.,]*$/, '')

// convert various protocol variants to regular URL at the public gateway
if (root === 'ipfs://') {
root = '/ipfs/'
} else if (root === 'ipns://') {
root = '/ipns/'
} else if (root === 'dweb:/ipfs/') {
root = '/ipfs/'
} else if (root === 'dweb:/ipns/') {
root = '/ipns/'
}
if (href.startsWith('ipns:')) {
href = href.replace('ipns:', '/ipns/')
return validIpfsResource(root + path)
}

async function validIpfsResource (path) {
// validation is expensive, caching result improved performance
// on page that have multiple copies of the same path
if (window.ipfsLinkifyValidationCache.has(path)) {
return window.ipfsLinkifyValidationCache.get(path)
}
if (href.startsWith('fs:')) {
href = href.replace('fs:', '')
try {
// Callback wrapped in promise -- Chrome compatibility
const checkResult = await browser.runtime.sendMessage({isIpfsPath: path})
if (checkResult.isIpfsPath) {
// TODO: use customizable public gateway
window.ipfsLinkifyValidationCache.set(path, 'https://ipfs.io' + path)
} else {
window.ipfsLinkifyValidationCache.set(path, null)
}
} catch (error) {
window.ipfsLinkifyValidationCache.set(path, null)
console.error('isIpfsPath.error for ' + path, error)
}
href = 'https://ipfs.io/' + href // for now just point to public gw, we will switch to custom protocol when https://github.com/ipfs/ipfs-companion/issues/164 is closed
href = href.replace(/([^:]\/)\/+/g, '$1') // remove redundant slashes
return href
return window.ipfsLinkifyValidationCache.get(path)
}

function linkifyTextNode (node) {
// console.log('linkifyTextNode', node)
async function linkifyTextNode (node) {
let link
let match
const txt = node.textContent
let span = null
let point = 0
while ((match = urlRE.exec(txt))) {
link = await textToIpfsResource(match)
if (span == null) {
// Create a span to hold the new text with links in it.
// Create a span to hold the new text with links in it.
span = document.createElement('span')
span.className = 'linkifiedIpfsAddress'
}
// get the link without trailing dots and commas
link = match[0].replace(/[.,]*$/, '')
const replaceLength = link.length
// put in text up to the link
span.appendChild(document.createTextNode(txt.substring(point, match.index)))
// create a link and put it in the span
const a = document.createElement('a')
a.className = 'linkifiedIpfsAddress'
a.appendChild(document.createTextNode(link))
a.setAttribute('href', normalizeHref(link.trim()))
span.appendChild(a)
// track insertion point
const replaceLength = match[0].length
if (link) {
// put in text up to the link
span.appendChild(document.createTextNode(txt.substring(point, match.index)))
// create a link and put it in the span
const a = document.createElement('a')
a.className = 'linkifiedIpfsAddress'
a.appendChild(document.createTextNode(match[0]))
a.setAttribute('href', link)
span.appendChild(a)
} else {
// wrap text in span to exclude it from future processing
span.appendChild(document.createTextNode(match[0]))
}
// track insertion point
point = match.index + replaceLength
}
if (span) {
// take the text after the last link
span.appendChild(document.createTextNode(txt.substring(point, txt.length)))
span.normalize()
// replace the original text with the new span
try {
node.parentNode.replaceChild(span, node)
} catch (e) {
console.error(e)
console.log(node)
// console.log(node)
}
}
}

init()
}(window.ipfsLinkifiedDOM))
2 changes: 1 addition & 1 deletion add-on/src/options/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@
<div>
<label for="linkify">
<dl>
<dt>Clickable IPFS Addresses</dt>
<dt>Linkify IPFS Addresses</dt>
<dd>Turn plaintext <code>/ipfs/</code> paths into clickable links</dd>
</dl>
</label>
Expand Down

0 comments on commit 44d1811

Please sign in to comment.