diff --git a/add-on/_locales/en/messages.json b/add-on/_locales/en/messages.json index ffeb2c1ea..9f78489da 100644 --- a/add-on/_locales/en/messages.json +++ b/add-on/_locales/en/messages.json @@ -120,7 +120,7 @@ "description": "A menu item in Browser Action pop-up and right-click context menu (panel_copyCurrentPublicGwUrl)" }, "panel_copyCurrentPublicGwUrlTooltip": { - "message": "A shareable link to this tab that works for anyone, even if they don't use IPFS.", + "message": "A shareable link to this tab that anyone can open. When public gateway sharing is off in Preferences, a native IPFS address is copied instead.", "description": "A menu item tooltip in Browser Action pop-up (panel_copyCurrentPublicGwUrlTooltip)" }, "panel_copyCurrentPermalink": { @@ -128,7 +128,7 @@ "description": "A menu item in Browser Action pop-up (panel_copyCurrentPermalink)" }, "panel_copyCurrentPermalinkTooltip": { - "message": "A link to a snapshot of this tab at this moment in time; it won't change, even if content changes later. This link works for anyone, even if they don't use IPFS.", + "message": "A link to a snapshot of this tab at this moment in time; it won't change, even if content changes later.", "description": "A menu item tooltip in Browser Action pop-up (panel_copyCurrentPermalinkTooltip)" }, "panel_contextMenuViewOnGateway": { @@ -259,6 +259,22 @@ "message": "External (Kubo RPC)", "description": "An option on the Preferences screen (option_ipfsNodeType_external)" }, + "option_ipfsNodeType_external_explainer": { + "message": "IPFS Companion controls your node over the Kubo RPC API, an HTTP interface served by Kubo, the standard IPFS node software. Don't have a node yet? Install Kubo (command line), or IPFS Desktop, a graphical app that runs the same Kubo daemon and provides the same RPC and Web UI in a nicer package.", + "description": "Explainer under the IPFS Node section on the Preferences screen (option_ipfsNodeType_external_explainer)" + }, + "option_ipfsNodeType_external_installKubo": { + "message": "Install Kubo (command line)", + "description": "Link under the IPFS Node section on the Preferences screen (option_ipfsNodeType_external_installKubo)" + }, + "option_ipfsNodeType_external_installDesktop": { + "message": "Install IPFS Desktop", + "description": "Link under the IPFS Node section on the Preferences screen (option_ipfsNodeType_external_installDesktop)" + }, + "option_ipfsNodeType_external_rpcDocs": { + "message": "Kubo RPC API docs", + "description": "Link under the IPFS Node section on the Preferences screen (option_ipfsNodeType_external_rpcDocs)" + }, "option_ipfsNodeType_brave": { "message": "Provided by Brave", "description": "An option on the Preferences screen (option_ipfsNodeType_brave)" @@ -300,15 +316,15 @@ "description": "An option title on the Preferences screen (option_useSubdomains_title)" }, "option_useSubdomains_description": { - "message": "Isolate content roots from each other by loading them as subdomains (at *.localhost) and creating a unique origin for each CID, IPNS, or DNSLink record.", + "message": "Isolate content roots from each other by loading them as subdomains (at *.localhost) and creating a unique origin for each CID, IPNS name, or DNSLink site.", "description": "An option description on the Preferences screen (option_useSubdomains_description)" }, "option_dnslinkRedirect_title": { - "message": "Load DNSLink Sites from Custom Gateway", + "message": "Load DNSLink Sites from Local Gateway", "description": "An option title on the Preferences screen (option_dnslinkRedirect_title)" }, "option_dnslinkRedirect_description": { - "message": "When global gateway redirect is enabled, include DNSLink websites by redirecting them to their respective /ipns/{fqdn} paths at your custom gateway.", + "message": "When redirects to your local gateway are enabled, include DNSLink websites by redirecting them to their respective /ipns/{fqdn} paths.", "description": "An option description on the Preferences screen (option_dnslinkRedirect_description)" }, "option_dnslinkDataPreload_title": { @@ -340,21 +356,29 @@ "description": "An option description on the Preferences screen (option_enabledOn_description)" }, "option_publicGatewayUrl_title": { - "message": "Default Public Gateway", + "message": "Public Path Gateway", "description": "An option title on the Preferences screen (option_publicGatewayUrl_title)" }, "option_publicGatewayUrl_description": { - "message": "Set the URL of a default public gateway to use in publicly shareable links and as a fallback if your local gateway is unavailable.", + "message": "A public gateway serving links like https://example.com/ipfs/CID. All content shares that one origin, which works everywhere but does not keep sites isolated from each other. Used as a fallback when your local gateway is unavailable, and for shareable links when the subdomain gateway below is empty or cannot serve the address.", "description": "An option description on the Preferences screen (option_publicGatewayUrl_description)" }, "option_publicSubdomainGatewayUrl_title": { - "message": "Default Public Subdomain Gateway", + "message": "Public Subdomain Gateway", "description": "An option title on the Preferences screen (option_publicSubdomainGatewayUrl_title)" }, "option_publicSubdomainGatewayUrl_description": { - "message": "Set the URL of a default public subdomain gateway for the recovery of broken subdomain gateways.", + "message": "A public gateway serving links like https://CID.ipfs.example.com. Each CID, IPNS name, or DNSLink site gets its own origin, keeping sites isolated the way browsers expect. Preferred for shareable links, and used to repair broken links to other subdomain gateways.", "description": "An option description on the Preferences screen (option_publicSubdomainGatewayUrl_description)" }, + "option_usePublicGatewaysForShare_title": { + "message": "Use Public Gateways for Shareable Links", + "description": "An option title on the Preferences screen (option_usePublicGatewaysForShare_title)" + }, + "option_usePublicGatewaysForShare_description": { + "message": "When on, copied links such as \"Copy Shareable Link\" and \"Copy Snapshot Link\" point at one of the public gateways above and open for everyone, including people who don't run IPFS. When off, or when both gateway URLs above are empty, a native ipfs:// or ipns:// address is copied instead.", + "description": "An option description on the Preferences screen (option_usePublicGatewaysForShare_description)" + }, "option_header_api": { "message": "Kubo RPC API", "description": "A section header on the Preferences screen (option_header_api)" @@ -380,11 +404,11 @@ "description": "An option title on the Preferences screen (option_automaticMode_title)" }, "option_automaticMode_description": { - "message": "Automatically enables automatic redirection to your local gateway when the Kubo RPC endpoint is accessible, and disable it when the endpoint is unavailable.", + "message": "Automatically enable redirection to your local gateway when the Kubo RPC endpoint is accessible, and disable it when the endpoint is unavailable.", "description": "An option description on the Preferences screen (option_automaticMode_description)" }, "option_automaticMode_description_subtext": { - "message": "Note: disabling this feature will result in static redirects, independent of the Kubo RPC endpoint's availability, and may produce error when your node is offline.", + "message": "Note: disabling this feature will result in static redirects, independent of the Kubo RPC endpoint's availability, and may produce errors when your node is offline.", "description": "An automatic mode option description on the Preferences screen (option_automaticMode_description_subtext)" }, "option_header_dnslink": { @@ -428,7 +452,7 @@ "description": "An option title on the Preferences screen (option_displayNotifications_title)" }, "option_displayNotifications_description": { - "message": "Display OS-level notifications when a link is copied to the clipboard,the API state changes, etc.", + "message": "Display OS-level notifications when a link is copied to the clipboard, the API state changes, etc.", "description": "An option description on the Preferences screen (option_displayNotifications_description)" }, "option_displayReleaseNotes_title": { @@ -480,7 +504,7 @@ "description": "An option title on the Preferences screen (option_recoverFailedHttpRequests_title)" }, "option_recoverFailedHttpRequests_description": { - "message": "Redirect failed HTTP requests for IPFS resources to the public gateway.", + "message": "Redirect failed requests for IPFS resources to another gateway: a public one when set, otherwise your local one.", "description": "An option description on the Preferences screen (option_recoverFailedHttpRequests_description)" }, "option_detectIpfsPathHeader_title": { @@ -751,6 +775,22 @@ "message": "Update your IPFS Companion preferences", "description": "Learn more link on the recovery screen (recovery_page_learn_more)" }, + "recovery_page_publicUrl": { + "message": "Public URL:", + "description": "Label before the public gateway URL on the recovery screen (recovery_page_publicUrl)" + }, + "recovery_page_nativeUrl": { + "message": "Native address:", + "description": "Label before the native ipfs:// address on the recovery screen, shown when no public gateway is set (recovery_page_nativeUrl)" + }, + "recovery_page_message_p2_native": { + "message": "The link below is a native IPFS address. To open it, run a local IPFS node, or set a public gateway in your preferences.", + "description": "Message on the recovery screen shown when no public gateway is set (recovery_page_message_p2_native)" + }, + "recovery_page_button_installDesktop": { + "message": "Install IPFS Desktop", + "description": "Button on the recovery screen shown when no public gateway is set (recovery_page_button_installDesktop)" + }, "request_permissions_page_title": { "message": "Grant Host Permissions | IPFS Companion", "description": "Title of the recovery page (recovery_page_title)" diff --git a/add-on/src/lib/copier.js b/add-on/src/lib/copier.js index 4cfc3a277..15dbfedf5 100644 --- a/add-on/src/lib/copier.js +++ b/add-on/src/lib/copier.js @@ -119,8 +119,8 @@ export default function createCopier (notify, ipfsPathValidator) { async copyAddressAtPublicGw (context, contextType) { const url = await findValueForContext(context, contextType) - const publicUrl = await ipfsPathValidator.resolveToPublicUrl(url) - await copyTextToClipboard(publicUrl, notify) + const shareableUrl = await ipfsPathValidator.resolveToShareableUrl(url) + await copyTextToClipboard(shareableUrl, notify) }, async copyPermalink (context, contextType) { diff --git a/add-on/src/lib/dnslink.js b/add-on/src/lib/dnslink.js index 845bfcbe5..d32c38dec 100644 --- a/add-on/src/lib/dnslink.js +++ b/add-on/src/lib/dnslink.js @@ -61,7 +61,11 @@ export default function createDnslinkResolver (getState) { // to load the correct path from IPFS // - https://github.com/ipfs/ipfs-companion/issues/298 const ipnsPath = dnslinkResolver.convertToIpnsPath(url) - const gateway = state.redirect && state.localGwAvailable ? state.gwURLString : state.pubGwURLString + // redirect to the local gateway when enabled, and fall back to it when + // no public gateway is configured + const gateway = (state.redirect && state.localGwAvailable) || !state.pubGwURLString + ? state.gwURLString + : state.pubGwURLString return pathAtHttpGateway(ipnsPath, gateway) } }, diff --git a/add-on/src/lib/inspector.js b/add-on/src/lib/inspector.js index 7350b03ec..fd07efc21 100644 --- a/add-on/src/lib/inspector.js +++ b/add-on/src/lib/inspector.js @@ -9,8 +9,10 @@ export default function createInspector (notify, ipfsPathValidator, getState) { async viewOnGateway (context, contextType) { const url = await findValueForContext(context, contextType) const ipfsPath = ipfsPathValidator.resolveToIpfsPath(url) - const gateway = getState().pubGwURLString - const gatewayUrl = pathAtHttpGateway(ipfsPath, gateway) + const { pubGwURLString, gwURLString } = getState() + // fall back to the local gateway when no public gateway is configured + // (a browser tab can't open a native ipfs:// URI) + const gatewayUrl = pathAtHttpGateway(ipfsPath, pubGwURLString || gwURLString) await browser.tabs.create({ url: gatewayUrl }) } // TODO: view in WebUI's Files diff --git a/add-on/src/lib/ipfs-companion.js b/add-on/src/lib/ipfs-companion.js index 212a312ce..e303e1887 100644 --- a/add-on/src/lib/ipfs-companion.js +++ b/add-on/src/lib/ipfs-companion.js @@ -191,8 +191,14 @@ export default async function init (inQuickImport = false) { // console.log((sender.tab ? 'Message from a content script:' + sender.tab.url : 'Message from the extension'), request) if (request.pubGwUrlForIpfsOrIpnsPath) { const path = request.pubGwUrlForIpfsOrIpnsPath - const { validIpfsOrIpns, resolveToPublicUrl } = ipfsPathValidator - const result = await validIpfsOrIpns(path) ? await resolveToPublicUrl(path) : null + const { validIpfsOrIpns, resolveToPublicUrl, resolveToLocalUrl } = ipfsPathValidator + let result = null + if (await validIpfsOrIpns(path)) { + // linkified hrefs must be loadable http(s) URLs; with no public gateway + // configured the resolver returns a native URI, use the local gateway then + const publicUrl = await resolveToPublicUrl(path) + result = publicUrl && publicUrl.startsWith('http') ? publicUrl : resolveToLocalUrl(path) + } return { pubGwUrlForIpfsOrIpnsPath: result } } } @@ -277,19 +283,20 @@ export default async function init (inQuickImport = false) { const url = info.currentTab.url info.isIpfsContext = ipfsPathValidator.isIpfsPageActionsContext(url) if (info.isIpfsContext) { - info.currentTabPublicUrl = await ipfsPathValidator.resolveToPublicUrl(url) + info.currentTabPublicUrl = await ipfsPathValidator.resolveToShareableUrl(url) info.currentTabContentPath = ipfsPathValidator.resolveToIpfsPath(url) if (resolveCache.has(url)) { - const [immutableIpfsPath, permalink, cid] = resolveCache.get(url) + const [immutableIpfsPath, cid] = resolveCache.get(url) info.currentTabImmutablePath = immutableIpfsPath - info.currentTabPermalink = permalink + // resolve the permalink fresh so it tracks the share toggle; the costly + // DAG lookup underneath is memoized, so this stays cheap + info.currentTabPermalink = await ipfsPathValidator.resolveToPermalink(url) info.currentTabCid = cid } else { // run async resolution in the next event loop so it does not block the UI setTimeout(async () => { resolveCache.set(url, [ await ipfsPathValidator.resolveToImmutableIpfsPath(url), - await ipfsPathValidator.resolveToPermalink(url), await ipfsPathValidator.resolveToCid(url) ]) await sendStatusUpdateToBrowserAction() @@ -665,12 +672,12 @@ export default async function init (inQuickImport = false) { state.gwURLString = state.gwURL.toString() break case 'publicGatewayUrl': - state.pubGwURL = new URL(change.newValue) - state.pubGwURLString = state.pubGwURL.toString() + state.pubGwURL = change.newValue ? new URL(change.newValue) : undefined + state.pubGwURLString = state.pubGwURL?.toString() break case 'publicSubdomainGatewayUrl': - state.pubSubdomainGwURL = new URL(change.newValue) - state.pubSubdomainGwURLString = state.pubSubdomainGwURL.toString() + state.pubSubdomainGwURL = change.newValue ? new URL(change.newValue) : undefined + state.pubSubdomainGwURLString = state.pubSubdomainGwURL?.toString() break case 'useCustomGateway': state.redirect = change.newValue diff --git a/add-on/src/lib/ipfs-import.js b/add-on/src/lib/ipfs-import.js index aa9ee3741..31675b210 100644 --- a/add-on/src/lib/ipfs-import.js +++ b/add-on/src/lib/ipfs-import.js @@ -5,7 +5,7 @@ import debug from 'debug' import browser from 'webextension-polyfill' import { redirectOptOutHint } from './ipfs-request.js' -import { ipfsContentPath } from './ipfs-path.js' +import { contentPathToSubdomainUrl, ipfsContentPath, pathAtHttpGateway } from './ipfs-path.js' const log = debug('ipfs-companion:import') log.error = debug('ipfs-companion:import:error') @@ -13,7 +13,7 @@ export const browserActionFilesCpImportCurrentTab = 'browserActionFilesCpImportC export function createIpfsImportHandler (getState, getIpfs, ipfsPathValidator, runtime, copier) { const { - resolveToPublicUrl, + resolveToShareableUrl, resolveToCid } = ipfsPathValidator const ipfsImportHandler = { @@ -23,8 +23,9 @@ export function createIpfsImportHandler (getState, getIpfs, ipfsPathValidator, r if (runtime.hasNativeProtocolHandler) { return { path, url: `ipfs://${cid}` } } else { - // open at public GW (will be redirected to local elsewhere, if enabled) - const url = new URL(path, state.pubGwURLString).toString() + // open at public GW (will be redirected to local elsewhere, if enabled); + // fall back to the local gateway when no public gateway is configured + const url = new URL(path, state.pubGwURLString || state.gwURLString).toString() return { path, url } } }, @@ -67,17 +68,26 @@ export function createIpfsImportHandler (getState, getIpfs, ipfsPathValidator, r // share wrapping dir path = `/ipfs/${root.cid}/` } - const url = await resolveToPublicUrl(path) + const url = await resolveToShareableUrl(path) await copier.copyTextToClipboard(url) }, async preloadFilesAtPublicGateway (files) { const state = getState() if (!state.preloadAtPublicGateway) return + // preload needs a public gateway; noop when none is configured + if (!state.pubGwURLString && !state.pubSubdomainGwURL) return for (const file of files) { if (file && file.cid) { const { path } = ipfsImportHandler.getIpfsPathAndNativeAddress(file.cid) - const preloadUrl = await resolveToPublicUrl(`${path}#${redirectOptOutHint}`) + const contentPath = `${path}#${redirectOptOutHint}` + // spread the load over a random configured gateway when both are set + const candidates = [ + state.pubGwURLString && pathAtHttpGateway(contentPath, state.pubGwURLString), + state.pubSubdomainGwURL && contentPathToSubdomainUrl(contentPath, state.pubSubdomainGwURL) + ].filter(Boolean) + if (!candidates.length) continue + const preloadUrl = candidates[Math.floor(Math.random() * candidates.length)] try { await fetch(preloadUrl, { method: 'HEAD' }) log('successfully preloaded', file) diff --git a/add-on/src/lib/ipfs-path.js b/add-on/src/lib/ipfs-path.js index d700431eb..1d016b71c 100644 --- a/add-on/src/lib/ipfs-path.js +++ b/add-on/src/lib/ipfs-path.js @@ -3,11 +3,15 @@ import pMemoize from 'p-memoize' import * as isIPFS from 'is-ipfs' import isFQDN from 'is-fqdn' +import { CID } from 'multiformats/cid' +import { base32 } from 'multiformats/bases/base32' +import { base36 } from 'multiformats/bases/base36' +import { peerIdFromString } from '@libp2p/peer-id' // 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(/\/$/, '') +export const dropSlash = url => url ? url.replace(/\/$/, '') : url // Turns URL or URIencoded path into a content path export function ipfsContentPath (urlOrPath, opts) { @@ -52,7 +56,107 @@ export function ipfsContentPath (urlOrPath, opts) { export function ipfsUri (urlOrPath) { const contentPath = ipfsContentPath(urlOrPath, { keepURIParams: true }) if (!contentPath) return null - return contentPath.replace(/^\/(ip[f|n]s)\//, '$1://') + return contentPathToNativeUri(contentPath) +} + +// Splits a content path into namespace, root and the remainder, e.g. +// /ipfs//a/b?x#y -> { ns: 'ipfs', root: '', rest: '/a/b?x#y' } +function parseContentPath (contentPath) { + const match = contentPath.match(/^\/(ipfs|ipns)\/([^/?#]+)(.*)$/) + if (!match) return null + const [, ns, root, rest] = match + return { ns, root, rest } +} + +// Turns a content path (/ipfs//... or /ipns//...) into a native +// ipfs:// or ipns:// URI. Browsers treat the authority of these URIs as a +// case-insensitive origin, so the root is normalized to a case-insensitive CID. +export function contentPathToNativeUri (contentPath) { + const parsed = parseContentPath(contentPath) + if (!parsed) return null + const { ns, root, rest } = parsed + return `${ns}://${toCaseInsensitiveRoot(ns, root)}${rest}` +} + +// Normalize the root of a native URI so it survives as a case-insensitive origin. +// Only ipfs and ipns are valid namespaces; anything else is a programmer error. +// Roots we cannot parse are returned unchanged. +export function toCaseInsensitiveRoot (ns, root) { + if (ns === 'ipfs') { + // any CID collapses to a base32 CIDv1 (its multicodec is preserved) + try { + return CID.parse(root).toV1().toString(base32) + } catch (e) { + return root + } + } + if (ns === 'ipns') { + // DNSLink hostnames contain a dot and are left as-is; peer ids (PeerId or + // CID) normalize to a base36 CIDv1 with the libp2p-key codec + if (root.includes('.')) return root + try { + return peerIdFromString(root).toCID().toString(base36) + } catch (e) { + return root + } + } + throw new Error(`toCaseInsensitiveRoot: unsupported namespace '${ns}'`) +} + +// A subdomain gateway puts the root (CID or IPNS name) in a DNS label, which +// public DNS caps at 63 characters. +const DNS_LABEL_MAX = 63 + +// Build a subdomain-gateway URL from a content path, e.g. +// /ipfs//x + https://dweb.link -> https://.ipfs.dweb.link/x +// (the label is normalized to a case-insensitive CID). A DNSLink hostname cannot +// be a subdomain label, so we share the FQDN website itself. Returns null when +// the path cannot be represented as a subdomain URL: a root that is not a valid +// hostname (e.g. an /ipns/ name with spaces), or a label longer than a DNS +// label allows (e.g. a CID with a sha2-512 multihash); callers fall back to a +// path gateway or a native URI. +export function contentPathToSubdomainUrl (contentPath, gwURL) { + const parsed = parseContentPath(contentPath) + if (!parsed) return null + const { ns, root, rest } = parsed + try { + if (ns === 'ipns' && root.includes('.')) { + return trimDoubleSlashes(new URL(`${gwURL.protocol}//${root}${rest}`).toString()) + } + const label = toCaseInsensitiveRoot(ns, root) + if (label.length > DNS_LABEL_MAX) return null + return trimDoubleSlashes(new URL(`${gwURL.protocol}//${label}.${ns}.${gwURL.host}${rest}`).toString()) + } catch (e) { + return null + } +} + +// Attach a content path to a public gateway for a shareable link, preferring +// the subdomain gateway (origin isolation) and falling back to the path +// gateway when the subdomain gateway is not set or cannot represent the path. +// Returns null when no configured gateway can serve it. +function publicShareUrl (contentPath, state) { + if (!contentPath) return null + const { pubGwURLString, pubSubdomainGwURL } = state + if (pubSubdomainGwURL) { + const subdomainUrl = contentPathToSubdomainUrl(contentPath, pubSubdomainGwURL) + if (subdomainUrl) return subdomainUrl + } + if (pubGwURLString) return pathAtHttpGateway(contentPath, pubGwURLString) + return null +} + +// Shareable form of an already-normalized content path: a public gateway URL +// when usePublicGatewaysForShare is on and a public gateway can serve the +// path, the native ipfs:// / ipns:// URI otherwise. Takes the path verbatim; +// it must not be re-parsed here, as another decodeURI pass would corrupt +// percent-encoded segments. +function shareUrlForContentPath (contentPath, state) { + if (state.usePublicGatewaysForShare) { + const publicUrl = publicShareUrl(contentPath, state) + if (publicUrl) return publicUrl + } + return contentPathToNativeUri(contentPath) } function subdomainPatternMatch (url) { @@ -104,6 +208,8 @@ export function trimHashAndSearch (urlString) { // Returns true if URL belongs to the gateway. // The check includes subdomain gateways and quirks of ipfs.io export function sameGateway (url, gwUrl) { + // no gateway configured: nothing can belong to it + if (!gwUrl) return false if (typeof url === 'string') { url = new URL(url) } @@ -141,8 +247,10 @@ export function sameGateway (url, gwUrl) { } for (const gwName of gws) { - // match against the end to include subdomain gateways - if (url.host.endsWith(gwName)) return true + // match the exact host or its subdomains (subdomain gateways); the label + // boundary keeps unrelated hosts sharing a suffix (nondefaultipfs.io vs + // ipfs.io) from matching + if (url.host === gwName || url.host.endsWith(`.${gwName}`)) return true } return false } @@ -226,22 +334,26 @@ export function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) { !sameGateway(url, apiURL))) // hide on api port }, - // Resolve URL or path to HTTP URL: - // - IPFS paths are attached to HTTP Path Gateway root - // - IPFS subdomains are attached to HTTP Subdomain Gateway root - // - URL of DNSLinked websites are returned as-is - // - // The purpose of this resolver is to always return a meaningful, publicly - // accessible URL that can be accessed without the need of IPFS client. - // TODO: add Local version + // Resolve URL or path to a public HTTP URL: + // - content paths are attached to the public path gateway, or to the + // public subdomain gateway when only that one is configured + // - subdomain gateway URLs are attached to the public subdomain gateway + // only: a subdomain request asks for origin isolation and is never + // downgraded to a path gateway + // - URLs of DNSLinked websites are returned as-is + // - the native ipfs:// / ipns:// URI is returned when no acceptable + // public gateway is configured async resolveToPublicUrl (urlOrPath) { const { pubSubdomainGwURL, pubGwURLString } = getState() const input = urlOrPath // NATIVE ipns:// with DNSLink requires simple protocol swap if (input.startsWith('ipns://')) { - const dnslinkUrl = new URL(input) - dnslinkUrl.protocol = 'https:' + // no public gateway configured: keep the native ipns:// URI + if (!pubGwURLString && !pubSubdomainGwURL) return input + // the non-special ipns: scheme cannot be swapped in-place via + // URL.protocol assignment, so rewrite it before parsing + const dnslinkUrl = new URL(input.replace(/^ipns:\/\//, 'https://')) const dnslink = await dnslinkResolver.readAndCacheDnslink(dnslinkUrl.hostname) if (dnslink) { return dnslinkUrl.toString() @@ -251,6 +363,10 @@ export function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) { // SUBDOMAINS // Detect *.dweb.link and other subdomain gateways if (isIPFS.subdomain(input)) { + // A subdomain request asks for origin isolation and is never + // downgraded to a path gateway. With no public subdomain gateway + // configured, copy the native ipfs:// / ipns:// URI instead. + if (!pubSubdomainGwURL) return ipfsUri(input) // Switch Origin to prefered public subdomain gateway (default: dweb.link) const subdomainUrl = swapSubdomainGateway(input, pubSubdomainGwURL) @@ -282,12 +398,42 @@ export function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) { const ipfsPath = ipfsContentPath(input, { keepURIParams: true }) // IPFS Paths should be attached to the public gateway - if (isIPFS.path(ipfsPath)) return pathAtHttpGateway(ipfsPath, pubGwURLString) + if (isIPFS.path(ipfsPath)) { + if (pubGwURLString) return pathAtHttpGateway(ipfsPath, pubGwURLString) + // a subdomain gateway can serve a content path too + if (pubSubdomainGwURL) { + const subdomainUrl = contentPathToSubdomainUrl(ipfsPath, pubSubdomainGwURL) + if (subdomainUrl) return subdomainUrl + } + // no public gateway configured: copy the native ipfs:// / ipns:// URI + return ipfsUri(input) + } // Return original URL as-is (eg. DNSLink domains) or null if not an URL return input && input.startsWith('http') ? input : null }, + // Resolve URL or path to a native ipfs:// or ipns:// URI: + // - Gateway URLs and CID-in-subdomain are turned into ipfs://… / ipns://… + // - DNSLinked websites resolve to ipns:// when a DNSLink is cached + // - Non-IPFS HTTP URLs are returned as-is, anything else yields null + resolveToNativeUri (urlOrPath) { + const ipfsPath = ipfsPathValidator.resolveToIpfsPath(urlOrPath) + if (ipfsPath) return contentPathToNativeUri(ipfsPath) + return urlOrPath && urlOrPath.startsWith('http') ? urlOrPath : null + }, + + // Resolve URL or path to the link used by "Copy Shareable Link": + // a public gateway URL when usePublicGatewaysForShare is on (subdomain + // gateway preferred, path gateway as fallback), or the native + // ipfs:// / ipns:// URI when it is off or no public gateway is set. + async resolveToShareableUrl (urlOrPath) { + const contentPath = ipfsPathValidator.resolveToIpfsPath(urlOrPath) + if (contentPath) return shareUrlForContentPath(contentPath, getState()) + // Non-IPFS HTTP URLs are returned as-is, anything else yields null + return urlOrPath && urlOrPath.startsWith('http') ? urlOrPath : null + }, + // Version of resolveToPublicUrl that always resolves to URL representing // path gateway at local machine (This is ok, as localhost gw will redirect // to correct subdomain) @@ -307,9 +453,12 @@ export function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) { async resolveToPermalink (urlOrPath, optionalGatewayUrl) { const input = urlOrPath const ipfsPath = await this.resolveToImmutableIpfsPath(input) - const gateway = optionalGatewayUrl || getState().pubGwURLString - if (ipfsPath) return pathAtHttpGateway(ipfsPath, gateway) - return input.startsWith('http') ? input : null + if (!ipfsPath) return input.startsWith('http') ? input : null + // an explicit gateway wins; otherwise the immutable path follows the same + // share preference as "Copy Shareable Link" (the path is already decoded, + // so it goes to the shared helper without another resolve pass) + if (optionalGatewayUrl) return pathAtHttpGateway(ipfsPath, optionalGatewayUrl) + return shareUrlForContentPath(ipfsPath, getState()) }, // Resolve URL or path to IPFS Path: diff --git a/add-on/src/lib/ipfs-request.js b/add-on/src/lib/ipfs-request.js index 6fc44b7c5..b1dba5c94 100644 --- a/add-on/src/lib/ipfs-request.js +++ b/add-on/src/lib/ipfs-request.js @@ -25,7 +25,9 @@ const recoverableNetworkErrors = new Set([ 'net::ERR_CONNECTION_TIMED_OUT', // eg. httpd is offline 'net::ERR_INTERNET_DISCONNECTED' // no network ]) -const recoverableHttpError = (code) => code && code >= 400 +// 4xx is the gateway answering about the resource (e.g. 404 for missing +// content); only 5xx means the gateway itself is unhealthy and worth recovering +const recoverableHttpError = (code) => code && code >= 500 // Tracking late redirects for edge cases such as https://github.com/ipfs-shipyard/ipfs-companion/issues/436 const onHeadersReceivedRedirect = new Set() @@ -182,7 +184,7 @@ export function createRequestModifier (getState, dnslinkResolver, ipfsPathValida } // poor-mans protocol handlers - https://github.com/ipfs/ipfs-companion/issues/164#issuecomment-328374052 if (state.catchUnhandledProtocols && mayContainUnhandledIpfsProtocol(request)) { - const fix = await normalizedUnhandledIpfsProtocol(request, state.pubGwURLString) + const fix = await normalizedUnhandledIpfsProtocol(request, state.pubGwURLString || state.gwURLString) if (fix) { return fix } @@ -190,7 +192,7 @@ export function createRequestModifier (getState, dnslinkResolver, ipfsPathValida // handler for protocol_handlers from manifest.json if (redirectingProtocolRequest(request)) { // fix path passed via custom protocol - const fix = normalizedRedirectingProtocolRequest(request, state.pubGwURLString) + const fix = normalizedRedirectingProtocolRequest(request, state.pubGwURLString || state.gwURLString) if (fix) { return fix } @@ -371,7 +373,7 @@ export function createRequestModifier (getState, dnslinkResolver, ipfsPathValida // (optional redirect to custom one can happen later) const url = new URL(request.url) const pathWithArgs = `${xIpfsPath}${url.search}${url.hash}` - const newUrl = pathAtHttpGateway(pathWithArgs, state.pubGwURLString) + const newUrl = pathAtHttpGateway(pathWithArgs, state.pubGwURLString || state.gwURLString) // redirect only if local node is around if (newUrl && state.localGwAvailable) { log(`onHeadersReceived: normalized ${request.url} to ${newUrl}`) @@ -434,10 +436,12 @@ export function createRequestModifier (getState, dnslinkResolver, ipfsPathValida // Check if error can be recovered by opening same content-addresed path // using active gateway (public or local, depending on redirect state) if (await isRecoverable(request, state, ipfsPathValidator)) { - const redirectUrl = await ipfsPathValidator.resolveToPublicUrl(request.url) - log(`onErrorOccurred: attempting to recover from network error (${request.error}) for ${request.url} → ${redirectUrl}`, request) - // We are unable to redirect in onErrorOccurred, but we can update the tab - return updateTabWithURL(request, redirectUrl, browser) + const redirectUrl = await recoveryUrlForFailedRequest(request, state, ipfsPathValidator, runtimeRoot) + if (redirectUrl) { + log(`onErrorOccurred: attempting to recover from network error (${request.error}) for ${request.url} → ${redirectUrl}`, request) + // We are unable to redirect in onErrorOccurred, but we can update the tab + return updateTabWithURL(request, redirectUrl, browser) + } } }, @@ -465,9 +469,11 @@ export function createRequestModifier (getState, dnslinkResolver, ipfsPathValida } if (await isRecoverable(request, state, ipfsPathValidator)) { - const redirectUrl = await ipfsPathValidator.resolveToPublicUrl(request.url) - log(`onCompleted: attempting to recover from HTTP Error ${request.statusCode} for ${request.url} → ${redirectUrl}`, request) - return updateTabWithURL(request, redirectUrl, browser) + const redirectUrl = await recoveryUrlForFailedRequest(request, state, ipfsPathValidator, runtimeRoot) + if (redirectUrl) { + log(`onCompleted: attempting to recover from HTTP Error ${request.statusCode} for ${request.url} → ${redirectUrl}`, request) + return updateTabWithURL(request, redirectUrl, browser) + } } } } @@ -667,19 +673,43 @@ function normalizedUnhandledIpfsProtocol (request, pubGwUrl) { // RECOVERY OF FAILED REQUESTS // =================================================================== +// Resolve a failed request to a loadable gateway URL for recovery: a +// configured public gateway first (skipped when the failure happened at that +// very gateway), then the local gateway when the node is online. Returns null +// when nothing loadable is left; callers fall back to the recovery page, so we +// never navigate a tab to a native ipfs:// URI that browsers without a +// protocol handler cannot open. +async function resolveToActiveGatewayUrl (url, state, ipfsPathValidator) { + const failedAtPublicGw = sameGateway(url, state.pubGwURL) || sameGateway(url, state.pubSubdomainGwURL) + if (!failedAtPublicGw && (state.pubGwURLString || state.pubSubdomainGwURL)) { + const publicUrl = await ipfsPathValidator.resolveToPublicUrl(url) + if (publicUrl && publicUrl.startsWith('http')) return publicUrl + } + if (state.localGwAvailable && state.nodeActive) return ipfsPathValidator.resolveToLocalUrl(url) + return null +} + +// The URL a failed request is recovered to: a loadable gateway URL when one +// exists, otherwise the extension's recovery page with the native URI in the +// hash, where the user can start a local node or install IPFS Desktop. +async function recoveryUrlForFailedRequest (request, state, ipfsPathValidator, runtimeRoot) { + const gatewayUrl = await resolveToActiveGatewayUrl(request.url, state, ipfsPathValidator) + if (gatewayUrl) return gatewayUrl + const nativeUri = ipfsPathValidator.resolveToNativeUri(request.url) + if (nativeUri && nativeUri.startsWith('ip')) { + return `${dropSlash(runtimeRoot)}${recoveryPagePath}#${encodeURIComponent(nativeUri)}` + } + return null +} + // Recovery check for onErrorOccurred (request.error) and onCompleted (request.statusCode) async function isRecoverable (request, state, ipfsPathValidator) { - // Note: we are unable to recover default public gateways without a local one const { error, statusCode, url } = request - const { redirect, localGwAvailable, pubGwURL, pubSubdomainGwURL } = state return (state.recoverFailedHttpRequests && request.type === 'main_frame' && (recoverableNetworkErrors.has(error) || recoverableHttpError(statusCode)) && - await ipfsPathValidator.publicIpfsOrIpnsResource(url) && - ((redirect && localGwAvailable) || - (!sameGateway(url, pubGwURL) && - !sameGateway(url, pubSubdomainGwURL)))) + await ipfsPathValidator.publicIpfsOrIpnsResource(url)) } // Recovery check for onErrorOccurred (request.error) diff --git a/add-on/src/lib/options.js b/add-on/src/lib/options.js index 61e1d8f63..911cef5f6 100644 --- a/add-on/src/lib/options.js +++ b/add-on/src/lib/options.js @@ -4,6 +4,21 @@ import isFQDN from 'is-fqdn' import { isIPv4, isIPv6 } from 'is-ip' import { POSSIBLE_NODE_TYPES } from './state.js' +// Public gateways prefilled on a fresh install; "Copy Shareable Link" builds +// links on the subdomain one. Clearing a gateway URL in Preferences opts out +// of it (shareable links then fall back to native ipfs:// and ipns:// URIs). +// To ship native sharing by default, flip +// DEFAULT_USE_PUBLIC_GATEWAYS_FOR_SHARE to false and set both gateway +// constants to ''; everything else already copes with unset gateways. +// Note: storeMissingOptions persists these values into every profile, so +// flipping the constants alone only changes fresh installs. To reach existing +// profiles, the flip also needs a migrateOptions entry that rewrites stored +// values still equal to the constants below (users who changed them keep +// their choice). +export const DEFAULT_PUBLIC_GATEWAY_URL = 'https://ipfs.io' +export const DEFAULT_PUBLIC_SUBDOMAIN_GATEWAY_URL = 'https://dweb.link' +export const DEFAULT_USE_PUBLIC_GATEWAYS_FOR_SHARE = true + /** * @type {Readonly} */ @@ -11,8 +26,12 @@ export const optionDefaults = Object.freeze({ active: true, // global ON/OFF switch, overrides everything else ipfsNodeType: 'external', ipfsNodeConfig: buildDefaultIpfsNodeConfig(), - publicGatewayUrl: 'https://ipfs.io', - publicSubdomainGatewayUrl: 'https://dweb.link', + publicGatewayUrl: DEFAULT_PUBLIC_GATEWAY_URL, + publicSubdomainGatewayUrl: DEFAULT_PUBLIC_SUBDOMAIN_GATEWAY_URL, + // When on, "Copy Shareable Link" copies a URL at one of the public gateways + // above (subdomain preferred); when off, or when no public gateway URL is + // set, it copies native ipfs:// and ipns:// URIs. + usePublicGatewaysForShare: DEFAULT_USE_PUBLIC_GATEWAYS_FOR_SHARE, useCustomGateway: true, useSubdomains: true, enabledOn: [], // hostnames with explicit integration opt-in @@ -93,8 +112,10 @@ export function safeURL (url, opts) { return url } -// Return string without trailing slash +// Return string without trailing slash (empty input stays empty, so optional +// gateway URLs can be cleared) export function guiURLString (url, opts) { + if (!url) return '' return safeURL(url, opts).toString().replace(/\/$/, '') } diff --git a/add-on/src/lib/state.js b/add-on/src/lib/state.js index 732059f9a..d4b5c267b 100644 --- a/add-on/src/lib/state.js +++ b/add-on/src/lib/state.js @@ -20,10 +20,11 @@ export function initState (options, overrides) { const state = Object.assign({}, options) // generate some additional values state.peerCount = offlinePeerCount - state.pubGwURL = safeURL(options.publicGatewayUrl) + // public gateways are optional: empty means "copy native ipfs:// URIs" + state.pubGwURL = options.publicGatewayUrl ? safeURL(options.publicGatewayUrl) : undefined state.pubGwURLString = state.pubGwURL?.toString() delete state.publicGatewayUrl - state.pubSubdomainGwURL = safeURL(options.publicSubdomainGatewayUrl) + state.pubSubdomainGwURL = options.publicSubdomainGatewayUrl ? safeURL(options.publicSubdomainGatewayUrl) : undefined state.pubSubdomainGwURLString = state.pubSubdomainGwURL?.toString() delete state.publicSubdomainGatewayUrl state.redirect = options.useCustomGateway diff --git a/add-on/src/options/forms/gateways-form.js b/add-on/src/options/forms/gateways-form.js index f18f5f6e0..b075f142f 100644 --- a/add-on/src/options/forms/gateways-form.js +++ b/add-on/src/options/forms/gateways-form.js @@ -20,6 +20,7 @@ export default function gatewaysForm ({ enabledOn, publicGatewayUrl, publicSubdomainGatewayUrl, + usePublicGatewaysForShare, onOptionChange }) { const onCustomGatewayUrlChange = onOptionChange('customGatewayUrl', (url) => guiURLString(url, { useLocalhostName: useSubdomains })) @@ -27,6 +28,9 @@ export default function gatewaysForm ({ const onUseSubdomainProxyChange = onOptionChange('useSubdomains') const onPublicGatewayUrlChange = onOptionChange('publicGatewayUrl', guiURLString) const onPublicSubdomainGatewayUrlChange = onOptionChange('publicSubdomainGatewayUrl', guiURLString) + const onUsePublicGatewaysForShareChange = onOptionChange('usePublicGatewaysForShare') + // sharing via public gateways needs at least one gateway URL set + const anyPublicGatewayConfigured = Boolean(publicGatewayUrl || publicSubdomainGatewayUrl) const onDisabledOnChange = onOptionChange('disabledOn', hostTextToArray) const onEnabledOnChange = onOptionChange('enabledOn', hostTextToArray) const mixedContentWarning = !secureContextUrl.test(customGatewayUrl) @@ -37,49 +41,6 @@ export default function gatewaysForm ({

${browser.i18n.getMessage('option_header_gateways')}

-
- - -
-
- - -
${supportRedirectToCustomGateway ? html`
` : null} +
+ + +
+
+ + +
+
+ +
${switchToggle({ id: 'usePublicGatewaysForShare', checked: usePublicGatewaysForShare && anyPublicGatewayConfigured, onchange: onUsePublicGatewaysForShareChange, disabled: !anyPublicGatewayConfigured })}
+
${supportRedirectToCustomGateway ? html`