Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: opt-in for loading webui from dnslink #738

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions add-on/_locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,14 @@
"message": "Turn plaintext /ipfs/ paths into clickable links",
"description": "An option description on the Preferences screen (option_linkify_description)"
},
"option_webuiFromDNSLink_title": {
"message": "Load the latest Web UI",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@olizilla would appreciate your thoughts on how to name and describe this feature.
My initial stab looks like this:

2019-07-18--22-48-30

"description": "An option title on the Preferences screen (option_webuiFromDNSLink_title)"
},
"option_webuiFromDNSLink_description": {
"message": "Replaces stable version provided by your node with one at /ipns/webui.ipfs.io (requires working DNS and a compatible backend)",
"description": "An option description on the Preferences screen (option_webuiFromDNSLink_description)"
},
"option_dnslinkPolicy_title": {
"message": "DNSLink Support",
"description": "An option title on the Preferences screen (option_dnslinkPolicy_title)"
Expand Down
55 changes: 19 additions & 36 deletions add-on/src/lib/ipfs-client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,9 @@ const debug = require('debug')
const log = debug('ipfs-companion:client')
log.error = debug('ipfs-companion:client:error')

const browser = require('webextension-polyfill')
const external = require('./external')
const embedded = require('./embedded')
const embeddedWithChromeSockets = require('./embedded-chromesockets')
const { webuiCid } = require('../state')

let client

Expand All @@ -33,7 +31,7 @@ async function initIpfsClient (opts) {

const instance = await client.init(opts)
easeApiChanges(instance)
_reloadIpfsClientDependents(instance) // async (API is present)
preloadWebui(instance, opts)
return instance
}

Expand All @@ -43,43 +41,28 @@ async function destroyIpfsClient () {
await client.destroy()
} finally {
client = null
await _reloadIpfsClientDependents() // sync (API stopped working)
}
}
}

function _isWebuiTab (url) {
const bundled = !url.startsWith('http') && url.includes('/webui/index.html#/')
const ipns = url.includes('/webui.ipfs.io/#/')
return bundled || ipns
}

async function _reloadIpfsClientDependents (instance, opts) {
// online || offline
if (browser.tabs && browser.tabs.query) {
const tabs = await browser.tabs.query({})
if (tabs) {
tabs.forEach((tab) => {
// detect bundled webui in any of open tabs
if (_isWebuiTab(tab.url)) {
browser.tabs.reload(tab.id)
log('reloading bundled webui')
}
})
}
}
// online only
if (client && instance) {
if (webuiCid && instance.refs) {
// Optimization: preload the root CID to speed up the first time
// Web UI is opened. If embedded js-ipfs is used it will trigger
// remote (always recursive) preload of entire DAG to one of preload nodes.
// This way when embedded node wants to load resource related to webui
// it will get it fast from preload nodes.
log(`preloading webui root at ${webuiCid}`)
instance.refs(webuiCid, { recursive: false })
}
}
function preloadWebui (instance, opts) {
// run only when client still exists and async fetch is possible
if (!(client && instance && opts.webuiURLString && typeof fetch === 'function')) return
// Optimization: preload the root CID to speed up the first time
// Web UI is opened. If embedded js-ipfs is used it will trigger
// remote (always recursive) preload of entire DAG to one of preload nodes.
// This way when embedded node wants to load resource related to webui
// it will get it fast from preload nodes.
const webuiUrl = opts.webuiURLString
log(`preloading webui root at ${webuiUrl}`)
return fetch(webuiUrl, { redirect: 'follow' })
.then(response => {
const webuiPath = new URL(response.url).pathname
log(`preloaded webui root at ${webuiPath}`)
// trigger recursive remote preload in js-ipfs
instance.refs(webuiPath, { recursive: false })
})
.catch(err => log.error(`failed to preload webui root`, err))
}

const movedFilesApis = ['add', 'addPullStream', 'addReadableStream', 'cat', 'catPullStream', 'catReadableStream', 'get', 'getPullStream', 'getReadableStream']
Expand Down
28 changes: 11 additions & 17 deletions add-on/src/lib/ipfs-companion.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ const log = debug('ipfs-companion:main')
log.error = debug('ipfs-companion:main:error')

const browser = require('webextension-polyfill')
const toMultiaddr = require('uri-to-multiaddr')
const { optionDefaults, storeMissingOptions, migrateOptions } = require('./options')
const { initState, offlinePeerCount } = require('./state')
const { initState, offlinePeerCount, buildWebuiURLString } = require('./state')
const { createIpfsPathValidator } = require('./ipfs-path')
const createDnslinkResolver = require('./dnslink')
const { createRequestModifier, redirectOptOutHint } = require('./ipfs-request')
Expand Down Expand Up @@ -98,7 +97,8 @@ module.exports = async function init () {
function registerListeners () {
const onBeforeSendInfoSpec = ['blocking', 'requestHeaders']
if (!runtime.isFirefox) {
// Chrome 72+ requires 'extraHeaders' for access to Referer header (used in cors whitelisting of webui)
// Chrome 72+ requires 'extraHeaders' for accessing all headers
// Note: we need this for code ensuring ipfs-http-client can talk to API without setting CORS
onBeforeSendInfoSpec.push('extraHeaders')
}
browser.webRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, { urls: ['<all_urls>'] }, onBeforeSendInfoSpec)
Expand Down Expand Up @@ -223,7 +223,7 @@ module.exports = async function init () {
peerCount: state.peerCount,
gwURLString: dropSlash(state.gwURLString),
pubGwURLString: dropSlash(state.pubGwURLString),
webuiRootUrl: state.webuiRootUrl,
webuiURLString: state.webuiURLString,
apiURLString: dropSlash(state.apiURLString),
redirect: state.redirect,
noRedirectHostnames: state.noRedirectHostnames,
Expand Down Expand Up @@ -419,13 +419,15 @@ module.exports = async function init () {
// See: https://github.com/ipfs/ipfs-companion/issues/286
try {
// pass the URL of user-preffered public gateway
// TOOD: plan to remove this
await browser.tabs.executeScript(details.tabId, {
code: `window.ipfsCompanionPubGwURL = '${state.pubGwURLString}'`,
matchAboutBlank: false,
allFrames: true,
runAt: 'document_start'
})
// inject script that normalizes `href` and `src` containing unhandled protocols
// TOOD: add deprecation warning and plan to remove this
await browser.tabs.executeScript(details.tabId, {
file: '/dist/bundles/normalizeLinksContentScript.bundle.js',
matchAboutBlank: false,
Expand All @@ -436,18 +438,6 @@ module.exports = async function init () {
console.error(`Unable to normalize links at '${details.url}' due to`, error)
}
}
if (details.url.startsWith(state.webuiRootUrl)) {
// Ensure API backend points at one from IPFS Companion
const apiMultiaddr = toMultiaddr(state.apiURLString)
await browser.tabs.executeScript(details.tabId, {
runAt: 'document_start',
code: `if (!localStorage.getItem('ipfsApi')) {
console.log('[ipfs-companion] Setting API to ${apiMultiaddr}');
localStorage.setItem('ipfsApi', '${apiMultiaddr}');
window.location.reload();
}`
})
}
}

// API STATUS UPDATES
Expand Down Expand Up @@ -643,6 +633,7 @@ module.exports = async function init () {
case 'ipfsApiUrl':
state.apiURL = new URL(change.newValue)
state.apiURLString = state.apiURL.toString()
state.webuiURLString = buildWebuiURLString(state)
shouldRestartIpfsClient = true
break
case 'ipfsApiPollMs':
Expand All @@ -651,7 +642,6 @@ module.exports = async function init () {
case 'customGatewayUrl':
state.gwURL = new URL(change.newValue)
state.gwURLString = state.gwURL.toString()
state.webuiRootUrl = `${state.gwURLString}ipfs/${state.webuiCid}/`
break
case 'publicGatewayUrl':
state.pubGwURL = new URL(change.newValue)
Expand All @@ -674,6 +664,10 @@ module.exports = async function init () {
shouldReloadExtension = true
state[key] = localStorage.debug = change.newValue
break
case 'webuiFromDNSLink':
state[key] = change.newValue
state.webuiURLString = buildWebuiURLString(state)
break
case 'linkify':
case 'catchUnhandledProtocols':
case 'displayNotifications':
Expand Down
128 changes: 40 additions & 88 deletions add-on/src/lib/ipfs-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ log.error = debug('ipfs-companion:request:error')
const LRU = require('lru-cache')
const IsIpfs = require('is-ipfs')
const { pathAtHttpGateway } = require('./ipfs-path')
const { buildWebuiURLString } = require('./state')
const redirectOptOutHint = 'x-ipfs-companion-no-redirect'
const recoverableErrors = new Set([
// Firefox
Expand All @@ -34,28 +35,6 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
const ignoredRequests = new LRU(requestCacheCfg)
const ignore = (id) => ignoredRequests.set(id, true)
const isIgnored = (id) => ignoredRequests.get(id) !== undefined

const acrhHeaders = new LRU(requestCacheCfg) // webui cors fix in Chrome
const originUrls = new LRU(requestCacheCfg) // request.originUrl workaround for Chrome
const originUrl = (request) => {
// Firefox and Chrome provide relevant value in different fields:
// (Firefox) request object includes full URL of origin document, return as-is
if (request.originUrl) return request.originUrl
// (Chrome) is lacking: `request.initiator` is just the origin (protocol+hostname+port)
// To reconstruct originUrl we read full URL from Referer header in onBeforeSendHeaders
// and cache it for short time
// TODO: when request.originUrl is available in Chrome the `originUrls` cache can be removed
const cachedUrl = originUrls.get(request.requestId)
if (cachedUrl) return cachedUrl
if (request.requestHeaders) {
const referer = request.requestHeaders.find(h => h.name === 'Referer')
if (referer) {
originUrls.set(request.requestId, referer.value)
return referer.value
}
}
}

const preNormalizationSkip = (state, request) => {
// skip requests to the custom gateway or API (otherwise we have too much recursion)
if (request.url.startsWith(state.gwURLString) || request.url.startsWith(state.apiURLString)) {
Expand Down Expand Up @@ -161,46 +140,23 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru

// Special handling of requests made to API
if (request.url.startsWith(state.apiURLString)) {
// Requests made by 'blessed' Web UI
// --------------------------------------------
// Goal: Web UI works without setting CORS at go-ipfs
// (Without this snippet go-ipfs will return HTTP 403 due to additional origin check on the backend)
const origin = originUrl(request)
if (origin && origin.startsWith(state.webuiRootUrl)) {
// console.log('onBeforeSendHeaders', request)
// console.log('onBeforeSendHeaders.origin', origin)
// Swap Origin to pass server-side check
// (go-ipfs returns HTTP 403 on origin mismatch if there are no CORS headers)
const swapOrigin = (at) => {
request.requestHeaders[at].value = request.requestHeaders[at].value.replace(state.gwURL.origin, state.apiURL.origin)
}
let foundAt = request.requestHeaders.findIndex(h => h.name === 'Origin')
if (foundAt > -1) swapOrigin(foundAt)
foundAt = request.requestHeaders.findIndex(h => h.name === 'Referer')
if (foundAt > -1) swapOrigin(foundAt)

// Save access-control-request-headers from preflight
foundAt = request.requestHeaders.findIndex(h => h.name && h.name.toLowerCase() === 'access-control-request-headers')
if (foundAt > -1) {
acrhHeaders.set(request.requestId, request.requestHeaders[foundAt].value)
// console.log('onBeforeSendHeaders FOUND access-control-request-headers', acrhHeaders.get(request.requestId))
}
// console.log('onBeforeSendHeaders fixed headers', request.requestHeaders)
}

// '403 - Forbidden' fix for Chrome and Firefox
// --------------------------------------------
// We remove Origin header from requests made to API URL and WebUI
// by js-ipfs-http-client running in WebExtension context to remove need
// for manual CORS whitelisting via Access-Control-Allow-Origin at go-ipfs
// We remove Origin header from requests made to API URL
// by js-ipfs-http-client running in WebExtension context.
// This act as unification of CORS behavior across all vendors,
// where behavior was non-deterministic and changed between releases.
// Without this, some users would need to do manual CORS whitelisting
// by adding webExtensionOrigin to API.Access-Control-Allow-Origin at their IPFS node.
// More info:
// Firefox: https://github.com/ipfs-shipyard/ipfs-companion/issues/622
// Chromium 71: https://github.com/ipfs-shipyard/ipfs-companion/pull/616
// Chromium 72: https://github.com/ipfs-shipyard/ipfs-companion/issues/630
const isWebExtensionOrigin = (origin) => {
// console.log(`origin=${origin}, webExtensionOrigin=${webExtensionOrigin}`)
// Chromium <= 71 returns opaque Origin as defined in
// Chromium <72 returns opaque Origin as defined in
// https://html.spec.whatwg.org/multipage/origin.html#ascii-serialisation-of-an-origin
// TODO: remove this when <72 is not used by users
if (origin == null || origin === 'null') {
return true
}
Expand Down Expand Up @@ -274,39 +230,15 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
return
}

// Special handling of requests made to API
if (request.url.startsWith(state.apiURLString)) {
// Special handling of requests made by 'blessed' Web UI from local Gateway
// Goal: Web UI works without setting CORS at go-ipfs
// (This includes 'ignored' requests: CORS needs to be fixed even if no redirect is done)
const origin = originUrl(request)
if (origin && origin.startsWith(state.webuiRootUrl) && request.responseHeaders) {
// console.log('onHeadersReceived', request)
const acaOriginHeader = { name: 'Access-Control-Allow-Origin', value: state.gwURL.origin }
const foundAt = findHeaderIndex(acaOriginHeader.name, request.responseHeaders)
if (foundAt > -1) {
request.responseHeaders[foundAt].value = acaOriginHeader.value
} else {
request.responseHeaders.push(acaOriginHeader)
}

// Restore access-control-request-headers from preflight
const acrhValue = acrhHeaders.get(request.requestId)
if (acrhValue) {
const acahHeader = { name: 'Access-Control-Allow-Headers', value: acrhValue }
const foundAt = findHeaderIndex(acahHeader.name, request.responseHeaders)
if (foundAt > -1) {
request.responseHeaders[foundAt].value = acahHeader.value
} else {
request.responseHeaders.push(acahHeader)
}
acrhHeaders.del(request.requestId)
// console.log('onHeadersReceived SET Access-Control-Allow-Headers', header)
}

// console.log('onHeadersReceived fixed headers', request.responseHeaders)
return { responseHeaders: request.responseHeaders }
}
// Recover from a broken DNSLink webui by redirecting back to CID one
// TODO: remove when both GO and JS ship support for /ipns/webui.ipfs.io on the API port
if (request.statusCode === 404 && request.url === state.webuiURLString && state.webuiFromDNSLink) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means we can ship this feature before ipfs/kubo#6530 lands

const stableWebui = buildWebuiURLString({
apiURLString: state.apiURLString,
webuiFromDNSLink: false
})
log(`opening webui via ${state.webuiURLString} is not supported yet, opening stable webui from ${stableWebui} instead`)
return { redirectUrl: stableWebui }
}

// Skip if request is marked as ignored
Expand All @@ -317,6 +249,7 @@ function createRequestModifier (getState, dnslinkResolver, ipfsPathValidator, ru
if (state.redirect) {
// Late redirect as a workaround for edge cases such as:
// - CORS XHR in Firefox: https://github.com/ipfs-shipyard/ipfs-companion/issues/436
// TODO: remove when Firefox with a fix landed in Stable channel
if (onHeadersReceivedRedirect.has(request.requestId)) {
onHeadersReceivedRedirect.delete(request.requestId)
if (state.dnslinkPolicy) {
Expand Down Expand Up @@ -529,6 +462,25 @@ function normalizedUnhandledIpfsProtocol (request, pubGwUrl) {
}
}

function findHeaderIndex (name, headers) {
return headers.findIndex(x => x.name && x.name.toLowerCase() === name.toLowerCase())
/* not used at the moment, but this heuristic may be useful in the future
// Note: Chrome 72+ requires 'extraHeaders' for access to Referer header
const originUrls = new LRU(requestCacheCfg) // request.originUrl workaround for Chrome
const originUrl = (request) => {
// Firefox and Chrome provide relevant value in different fields:
// (Firefox) request object includes full URL of origin document, return as-is
if (request.originUrl) return request.originUrl
// (Chrome) is lacking: `request.initiator` is just the origin (protocol+hostname+port)
// To reconstruct originUrl we read full URL from Referer header in onBeforeSendHeaders
// and cache it for short time
// TODO: when request.originUrl is available in Chrome the `originUrls` cache can be removed
const cachedUrl = originUrls.get(request.requestId)
if (cachedUrl) return cachedUrl
if (request.requestHeaders) {
const referer = request.requestHeaders.find(h => h.name === 'Referer')
if (referer) {
originUrls.set(request.requestId, referer.value)
return referer.value
}
}
}
*/
1 change: 1 addition & 0 deletions add-on/src/lib/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ exports.optionDefaults = Object.freeze({
ipfsApiUrl: buildIpfsApiUrl(),
ipfsApiPollMs: 3000,
ipfsProxy: true, // window.ipfs
webuiFromDNSLink: false,
logNamespaces: 'jsipfs*,ipfs*,-*:ipns*,-ipfs:preload*,-ipfs-http-client:request*'
})

Expand Down
Loading