diff --git a/add-on/_locales/en/messages.json b/add-on/_locales/en/messages.json index 12f084a93..05e20d2d0 100644 --- a/add-on/_locales/en/messages.json +++ b/add-on/_locales/en/messages.json @@ -750,5 +750,33 @@ "option_telemetryGroupTracking_description": { "message": "Tracking description", "description": "A description for the 'tracking' grouping of metrics we collect (option_telemetryGroupTracking_description)" + }, + "recovery_page_title" : { + "message": "Problem with your IPFS node | IPFS Companion", + "description": "Title of the recovery page (recovery_page_title)" + }, + "recovery_page_sub_header": { + "message": "Unable to reach your IPFS node :(", + "description": "Sub-Header on the recovery screen (recovery_page_sub_header)" + }, + "recovery_page_message_p1": { + "message": "Ensure your IPFS node runs and provides HTTP Gateway.", + "description": "Message Para-1 on the recovery screen (recovery_page_message_p1)" + }, + "recovery_page_message_p2": { + "message": "You can also access deserialized version of the requested resource through the preferred public gateway set up in IPFS Companion. This delegates trust to a third-party address below, and skips local hash validation.", + "description": "Message Para-2 on the recovery screen (recovery_page_message_p2)" + }, + "recovery_page_button": { + "message": "Continue to the public gateway", + "description": "Button on the recovery screen (recovery_page_button)" + }, + "recovery_page_learn_more": { + "message": "Learn more about public gateways", + "description": "Learn more link on the recovery screen (recovery_page_learn_more)" + }, + "recovery_page_update_preferences": { + "message": "Update your IPFS Companion preferences", + "description": "Learn more link on the recovery screen (recovery_page_learn_more)" } } diff --git a/add-on/manifest.common.json b/add-on/manifest.common.json index 461d3aad4..db717fd9d 100644 --- a/add-on/manifest.common.json +++ b/add-on/manifest.common.json @@ -33,7 +33,10 @@ "icons/png/ipfs-logo-off_38.png", "icons/png/ipfs-logo-off_128.png", "icons/ipfs-logo-on.svg", - "icons/ipfs-logo-off.svg" + "icons/ipfs-logo-off.svg", + "dist/recovery/recovery.css", + "dist/recovery/recovery.html", + "dist/recovery/recovery.js" ], "content_security_policy": "script-src 'self'; object-src 'self'; frame-src 'self';", "default_locale": "en" diff --git a/add-on/src/landing-pages/welcome/page.js b/add-on/src/landing-pages/welcome/page.js index a83722aeb..5361dc307 100644 --- a/add-on/src/landing-pages/welcome/page.js +++ b/add-on/src/landing-pages/welcome/page.js @@ -47,16 +47,24 @@ export default function createWelcomePage (i18n) { /* ======================================================== Render functions for the left side ======================================================== */ - -const renderCompanionLogo = (i18n, isIpfsOnline) => { +export const renderLogo = (isIpfsOnline, logoSize = 128) => { const logoPath = '../../../icons' - const logoSize = 128 + + return html` + ${logo({ path: logoPath, size: logoSize, isIpfsOnline })} + ` +} + +export const renderCompanionLogo = (i18n, isIpfsOnline, showTitle = true) => { const stateUnknown = isIpfsOnline === null return html`
- ${logo({ path: logoPath, size: logoSize, isIpfsOnline })} -

${i18n.getMessage('page_landingWelcome_logo_title')}

+ ${renderLogo(isIpfsOnline)} + ${showTitle + ? html`

${i18n.getMessage('page_landingWelcome_logo_title')}

` + : '' + }
` } @@ -88,17 +96,16 @@ const renderWelcome = (i18n, peerCount, openWebUi) => { ` } +export const nodeOffSvg = (svgWidth = 130) => html` + + + +` + const renderInstallSteps = (i18n, isIpfsOnline) => { const copyClass = 'mv0 white f5 lh-copy' const anchorClass = 'aqua hover-white' const stateUnknown = isIpfsOnline === null - const svgWidth = 130 - - const nodeOffSvg = () => html` - - - - ` const optionsUrl = browser.runtime.getURL(optionsPage) return html` diff --git a/add-on/src/lib/constants.js b/add-on/src/lib/constants.js index 6de4ce58c..ef2f7c61a 100644 --- a/add-on/src/lib/constants.js +++ b/add-on/src/lib/constants.js @@ -3,4 +3,5 @@ export const welcomePage = '/dist/landing-pages/welcome/index.html' export const optionsPage = '/dist/options/options.html' +export const recoveryPagePath = '/dist/recovery/recovery.html' export const tickMs = 250 // no CPU spike, but still responsive enough diff --git a/add-on/src/lib/ipfs-companion.js b/add-on/src/lib/ipfs-companion.js index 0d3c1d7a0..b055f599e 100644 --- a/add-on/src/lib/ipfs-companion.js +++ b/add-on/src/lib/ipfs-companion.js @@ -10,7 +10,7 @@ import LRU from 'lru-cache' import all from 'it-all' import { optionDefaults, storeMissingOptions, migrateOptions, guiURLString, safeURL } from './options.js' import { initState, offlinePeerCount } from './state.js' -import { createIpfsPathValidator, sameGateway, safeHostname } from './ipfs-path.js' +import { createIpfsPathValidator, dropSlash, sameGateway, safeHostname } from './ipfs-path.js' import createDnslinkResolver from './dnslink.js' import { createRequestModifier } from './ipfs-request.js' import { initIpfsClient, destroyIpfsClient, reloadIpfsClientOfflinePages } from './ipfs-client/index.js' @@ -224,7 +224,6 @@ export default async function init () { async function sendStatusUpdateToBrowserAction () { if (!browserActionPort) return - const dropSlash = url => url.replace(/\/$/, '') const currentTab = await browser.tabs.query({ active: true, currentWindow: true }).then(tabs => tabs[0]) const { version } = browser.runtime.getManifest() const info = { diff --git a/add-on/src/lib/ipfs-path.js b/add-on/src/lib/ipfs-path.js index 64ede8bf0..2490d92ae 100644 --- a/add-on/src/lib/ipfs-path.js +++ b/add-on/src/lib/ipfs-path.js @@ -8,6 +8,8 @@ import isFQDN from 'is-fqdn' // For how long more expensive lookups (DAG traversal etc) should be cached const RESULT_TTL_MS = 300000 // 5 minutes +export const dropSlash = url => url.replace(/\/$/, '') + // Turns URL or URIencoded path into a content path export function ipfsContentPath (urlOrPath, opts) { opts = opts || {} diff --git a/add-on/src/lib/ipfs-request.js b/add-on/src/lib/ipfs-request.js index a0e268f77..e20e568ff 100644 --- a/add-on/src/lib/ipfs-request.js +++ b/add-on/src/lib/ipfs-request.js @@ -6,9 +6,11 @@ import debug from 'debug' import LRU from 'lru-cache' import isIPFS from 'is-ipfs' import isFQDN from 'is-fqdn' -import { pathAtHttpGateway, sameGateway, ipfsUri } from './ipfs-path.js' +import { dropSlash, ipfsUri, pathAtHttpGateway, sameGateway } from './ipfs-path.js' import { safeURL } from './options.js' import { braveNodeType } from './ipfs-client/brave.js' +import { recoveryPagePath } from './constants.js' + const log = debug('ipfs-companion:request') log.error = debug('ipfs-companion:request:error') @@ -140,6 +142,13 @@ export function createRequestModifier (getState, dnslinkResolver, ipfsPathValida const state = getState() if (!state.active) return + // When local IPFS node is unreachable , show recovery page where user can redirect + // to public gateway. + if (!state.nodeActive && request.type === 'main_frame' && sameGateway(request.url, state.gwURL)) { + const publicUri = ipfsPathValidator.resolveToPublicUrl(request.url, state.pubGwURLString) + return { redirectUrl: `${dropSlash(runtimeRoot)}${recoveryPagePath}#${encodeURIComponent(publicUri)}` } + } + // When Subdomain Proxy is enabled we normalize address bar requests made // to the local gateway and replace raw IP with 'localhost' hostname to // take advantage of subdomain redirect provided by go-ipfs >= 0.5 diff --git a/add-on/src/lib/state.js b/add-on/src/lib/state.js index 6ba34cb24..0a99fb2f2 100644 --- a/add-on/src/lib/state.js +++ b/add-on/src/lib/state.js @@ -50,8 +50,11 @@ export function initState (options, overrides) { return false } } - // TODO state.connected ~= state.peerCount > 0 - // TODO state.nodeActive ~= API is online,eg. state.peerCount > offlinePeerCount + // TODO refactor this into a class. It's getting too big and messy. + Object.defineProperty(state, 'nodeActive', { + // TODO: make quick fetch to confirm it works? + get: function () { return this.peerCount !== offlinePeerCount } + }) Object.defineProperty(state, 'localGwAvailable', { // TODO: make quick fetch to confirm it works? get: function () { return this.ipfsNodeType !== 'embedded' } diff --git a/add-on/src/recovery/recovery.css b/add-on/src/recovery/recovery.css new file mode 100644 index 000000000..786182825 --- /dev/null +++ b/add-on/src/recovery/recovery.css @@ -0,0 +1,54 @@ +@import url('~tachyons/css/tachyons.css'); +@import url('~ipfs-css/ipfs.css'); + +#left-col { + background-image: url('../../images/stars.png'), linear-gradient(to bottom, #041727 0%, #043b55 100%); + background-size: 100%; + background-repeat: repeat; +} + +a:hover { + text-decoration: none; +} + +a:visited { + color: inherit; +} + +/* + https://github.com/tachyons-css/tachyons-queries + Tachyons: $point == large +*/ +@media (min-width: 60em) { + #left-col { + position: fixed; + top: 0; + right: 55%; + width: 45%; + background-image: url('../../images/stars.png'), linear-gradient(to bottom, #041727 0%, #043b55 100%); + background-size: 100%; + background-repeat: repeat; + } + + #right-col { + margin-left: 54%; + margin-right: 6%; + } +} + +@media (max-height: 800px) { + #left-col img { + width: 98px !important; + height: 98px !important; + } + + #left-col svg { + width: 60px; + } +} + +.recovery-root { + width: 100%; + height: 100%; + text-align: left; +} diff --git a/add-on/src/recovery/recovery.html b/add-on/src/recovery/recovery.html new file mode 100644 index 000000000..af492fbe3 --- /dev/null +++ b/add-on/src/recovery/recovery.html @@ -0,0 +1,20 @@ + + + + IPFS Node is Offline + + + + + + + + +
+
+
+ + +
+ + diff --git a/add-on/src/recovery/recovery.js b/add-on/src/recovery/recovery.js new file mode 100644 index 000000000..e749123f8 --- /dev/null +++ b/add-on/src/recovery/recovery.js @@ -0,0 +1,75 @@ +'use strict' +/* eslint-env browser, webextensions */ + +import choo from 'choo' +import html from 'choo/html/index.js' +import browser, { i18n, runtime } from 'webextension-polyfill' +import { nodeOffSvg } from '../landing-pages/welcome/page.js' +import createWelcomePageStore from '../landing-pages/welcome/store.js' +import { optionsPage } from '../lib/constants.js' +import './recovery.css' + +const app = choo() + +const learnMoreLink = html`${i18n.getMessage('recovery_page_learn_more')}` + +const optionsPageLink = html`${i18n.getMessage('recovery_page_update_preferences')}` + +// TODO (whizzzkid): refactor base store to be more generic. +app.use(createWelcomePageStore(i18n, runtime)) +// Register our single route +app.route('*', (state) => { + browser.runtime.sendMessage({ telemetry: { trackView: 'recovery' } }) + const { hash } = window.location + const { href: publicURI } = new URL(decodeURIComponent(hash.slice(1))) + + if (!publicURI) { + return + } + + const openURLFromHash = () => { + try { + console.log('Opening URL from hash:', publicURI) + window.location.replace(publicURI) + } catch (err) { + console.error('Failed to open URL from hash:', err) + } + } + + // if the IPFS node is online, open the URL from the hash, this will redirect to the local node. + if (state.isIpfsOnline) { + openURLFromHash() + return + } + + return html`
+
+
+ ${nodeOffSvg(200)} +

${i18n.getMessage('recovery_page_sub_header')}

+
+
+ +
+

${i18n.getMessage('recovery_page_message_p1')}

+

${i18n.getMessage('recovery_page_message_p2')}

+

Public URL: ${publicURI}

+ +

+ ${learnMoreLink} | ${optionsPageLink} + +

+
` +}) + +// Start the application and render it to the given querySelector +app.mount('#root') + +// Set page title and header translation +document.title = i18n.getMessage('recovery_page_title') diff --git a/test/functional/lib/ipfs-request-gateway-redirect.test.js b/test/functional/lib/ipfs-request-gateway-redirect.test.js index 6389764c4..07d3ccacc 100644 --- a/test/functional/lib/ipfs-request-gateway-redirect.test.js +++ b/test/functional/lib/ipfs-request-gateway-redirect.test.js @@ -33,6 +33,7 @@ describe('modifyRequest.onBeforeRequest:', function () { global.URL = URL global.browser = browser browser.runtime.id = 'testid' + browser.runtime.getURL.returns('chrome-extension://testid/') }) beforeEach(async function () { @@ -425,6 +426,24 @@ describe('modifyRequest.onBeforeRequest:', function () { }) }) + describe('Recovers Page if node is unreachable', function () { + beforeEach(function () { + global.browser = browser + state.ipfsNodeType = 'external' + state.redirect = true + state.peerCount = -1 + state.gwURLString = 'http://localhost:8080' + state.gwURL = new URL('http://localhost:8080') + state.pubGwURLString = 'https://ipfs.io' + state.pubGwURL = new URL('https://ipfs.io') + }) + it('should present recovery page if node is offline', function () { + expect(state.nodeActive).to.be.equal(false) + const request = url2request('https://localhost:8080/ipfs/QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR/foo/bar') + expect(modifyRequest.onBeforeRequest(request).redirectUrl).to.equal('chrome-extension://testid/dist/recovery/recovery.html#https%3A%2F%2Fipfs.io%2Fipfs%2FQmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR%2Ffoo%2Fbar') + }) + }) + after(function () { delete global.URL delete global.browser diff --git a/webpack.config.js b/webpack.config.js index 85a19ba3f..e47214c3a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -156,6 +156,7 @@ const uiConfig = merge(commonConfig, { browserAction: './add-on/src/popup/browser-action/index.js', importPage: './add-on/src/popup/quick-import.js', optionsPage: './add-on/src/options/options.js', + recoveryPage: './add-on/src/recovery/recovery.js', welcomePage: './add-on/src/landing-pages/welcome/index.js' }, optimization: {