diff --git a/add-on/_locales/en/messages.json b/add-on/_locales/en/messages.json index e94a84f29..7149b8fbe 100644 --- a/add-on/_locales/en/messages.json +++ b/add-on/_locales/en/messages.json @@ -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", + "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)" diff --git a/add-on/src/lib/ipfs-client/index.js b/add-on/src/lib/ipfs-client/index.js index c7b69ecae..d65811d21 100644 --- a/add-on/src/lib/ipfs-client/index.js +++ b/add-on/src/lib/ipfs-client/index.js @@ -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 @@ -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 } @@ -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'] diff --git a/add-on/src/lib/ipfs-companion.js b/add-on/src/lib/ipfs-companion.js index ffc6f455b..a39287b40 100644 --- a/add-on/src/lib/ipfs-companion.js +++ b/add-on/src/lib/ipfs-companion.js @@ -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') @@ -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: [''] }, onBeforeSendInfoSpec) @@ -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, @@ -419,6 +419,7 @@ 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, @@ -426,6 +427,7 @@ module.exports = async function init () { 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, @@ -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 @@ -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': @@ -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) @@ -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': diff --git a/add-on/src/lib/ipfs-request.js b/add-on/src/lib/ipfs-request.js index 5e5871b2a..1fa0ccd73 100644 --- a/add-on/src/lib/ipfs-request.js +++ b/add-on/src/lib/ipfs-request.js @@ -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 @@ -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)) { @@ -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 } @@ -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) { + 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 @@ -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) { @@ -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 + } + } } +*/ diff --git a/add-on/src/lib/options.js b/add-on/src/lib/options.js index b2dd54b22..9a0fd3deb 100644 --- a/add-on/src/lib/options.js +++ b/add-on/src/lib/options.js @@ -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*' }) diff --git a/add-on/src/lib/state.js b/add-on/src/lib/state.js index b9fa09503..8c1d4802b 100644 --- a/add-on/src/lib/state.js +++ b/add-on/src/lib/state.js @@ -2,11 +2,8 @@ /* eslint-env browser, webextensions */ 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 +const offlinePeerCount = -1 function initState (options) { // we store options and some pregenerated values to avoid async storage @@ -26,11 +23,17 @@ 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.webuiURLString = buildWebuiURLString(state) return state } +function buildWebuiURLString ({ apiURLString, webuiFromDNSLink }) { + if (!apiURLString) throw new Error('Missing apiURLString') + return webuiFromDNSLink + ? `${apiURLString}ipns/webui.ipfs.io/` + : `${apiURLString}webui/` +} + exports.initState = initState exports.offlinePeerCount = offlinePeerCount -exports.webuiCid = webuiCid +exports.buildWebuiURLString = buildWebuiURLString diff --git a/add-on/src/options/forms/experiments-form.js b/add-on/src/options/forms/experiments-form.js index ccd03a9a9..09b57aed5 100644 --- a/add-on/src/options/forms/experiments-form.js +++ b/add-on/src/options/forms/experiments-form.js @@ -10,6 +10,7 @@ function experimentsForm ({ preloadAtPublicGateway, catchUnhandledProtocols, linkify, + webuiFromDNSLink, dnslinkPolicy, detectIpfsPathHeader, ipfsProxy, @@ -24,6 +25,7 @@ function experimentsForm ({ const onDnslinkPolicyChange = onOptionChange('dnslinkPolicy') const onDetectIpfsPathHeaderChange = onOptionChange('detectIpfsPathHeader') const onIpfsProxyChange = onOptionChange('ipfsProxy') + const onWebuiFromDNSLinkChange = onOptionChange('webuiFromDNSLink') return html`
@@ -66,6 +68,15 @@ function experimentsForm ({
${switchToggle({ id: 'linkify', checked: linkify, onchange: onLinkifyChange })}
+
+ +
${switchToggle({ id: 'webuiFromDNSLink', checked: webuiFromDNSLink, onchange: onWebuiFromDNSLinkChange })}
+