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

refactor: use Web UI provided by IPFS node #737

Closed
wants to merge 1 commit 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
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.webuiRootUrl && 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.webuiRootUrl
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
20 changes: 5 additions & 15 deletions add-on/src/lib/ipfs-companion.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ 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 { createIpfsPathValidator } = require('./ipfs-path')
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 @@ -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.webuiRootUrl = `${state.apiURLString}webui`
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 Down
120 changes: 30 additions & 90 deletions add-on/src/lib/ipfs-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,28 +34,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 +139,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,41 +229,6 @@ 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 }
}
}

// Skip if request is marked as ignored
if (isIgnored(request.requestId)) {
return
Expand All @@ -317,6 +237,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 +450,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
}
}
}
*/
8 changes: 1 addition & 7 deletions add-on/src/lib/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@
const { safeURL } = require('./options')
const offlinePeerCount = -1

// CID of a 'blessed' Web UI release
// which should work without setting CORS headers
const webuiCid = 'QmYTRvKFGhxgBiUreiw7ihn8g95tfJTWDt7aXqDsvAcJse' // v2.4.7

function initState (options) {
// we store options and some pregenerated values to avoid async storage
// reads and minimize performance impact on overall browsing experience
Expand All @@ -26,11 +22,9 @@ function initState (options) {
state.gwURLString = state.gwURL.toString()
delete state.customGatewayUrl
state.dnslinkPolicy = String(options.dnslinkPolicy) === 'false' ? false : options.dnslinkPolicy
state.webuiCid = webuiCid
state.webuiRootUrl = `${state.gwURLString}ipfs/${state.webuiCid}/`
state.webuiRootUrl = `${state.apiURLString}webui`
return state
}

exports.initState = initState
exports.offlinePeerCount = offlinePeerCount
exports.webuiCid = webuiCid
Loading