From 79ab0da36e96ba274eecfb573f9cf4723b15c3c4 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 1 Jul 2026 01:12:06 +0200 Subject: [PATCH 1/5] feat: make public gateways optional for sharing Public gateway URLs are now empty by default, so "Copy Shareable Link" and friends copy native ipfs:// and ipns:// URIs. A new opt-in toggle restores public gateway URLs for sharing with people who do not run IPFS. - options: empty publicGatewayUrl/publicSubdomainGatewayUrl defaults plus usePublicGatewaysForShare (off, disabled until a public gateway URL is set) - ipfs-path: resolveToNativeUri plus toggle-aware resolveToShareableUrl and resolveToPermalink (Copy Shareable and Snapshot Link); resolveToPublicUrl falls back to native on empty gateway - public shareable links follow the "Use Subdomains" preference: on uses the subdomain gateway, off the path gateway, and whichever one is configured when only one is set - native URIs use case-insensitive roots since browsers treat them as origins: base32 CIDv1 for ipfs://, base36 libp2p-key CIDv1 for ipns:// peer ids (DNSLink hostnames unchanged) - empty gateways handled across state, request, dnslink, import and inspector: sameGateway/dropSlash are null-safe and browser-loading paths fall back to the local gateway - recovery page: with no public gateway configured it shows the native ipfs:// address, drops the public-gateway wording, and offers to install a local IPFS node - options UI: public gateway fields moved below the local gateway; path gateway renamed to make its shape explicit; both documented with link examples and links to the path/subdomain gateway specs --- add-on/_locales/en/messages.json | 32 +++- add-on/src/lib/copier.js | 4 +- add-on/src/lib/dnslink.js | 6 +- add-on/src/lib/inspector.js | 6 +- add-on/src/lib/ipfs-companion.js | 17 +- add-on/src/lib/ipfs-import.js | 8 +- add-on/src/lib/ipfs-path.js | 117 ++++++++++++- add-on/src/lib/ipfs-request.js | 36 ++-- add-on/src/lib/options.js | 12 +- add-on/src/lib/state.js | 5 +- add-on/src/options/forms/gateways-form.js | 102 ++++++----- add-on/src/options/page.js | 1 + add-on/src/recovery/recovery.js | 33 ++-- add-on/src/types/companion.d.ts | 9 +- test/functional/lib/ipfs-path.test.js | 159 +++++++++++++++++- .../lib/ipfs-request-gateway-recover.test.js | 13 +- 16 files changed, 452 insertions(+), 108 deletions(-) diff --git a/add-on/_locales/en/messages.json b/add-on/_locales/en/messages.json index ffeb2c1ea..64486f69d 100644 --- a/add-on/_locales/en/messages.json +++ b/add-on/_locales/en/messages.json @@ -300,7 +300,7 @@ "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, or DNSLink record. When public gateway sharing is on, this also picks the shareable link shape: on uses your subdomain gateway, off your path gateway.", "description": "An option description on the Preferences screen (option_useSubdomains_description)" }, "option_dnslinkRedirect_title": { @@ -340,11 +340,11 @@ "description": "An option description on the Preferences screen (option_enabledOn_description)" }, "option_publicGatewayUrl_title": { - "message": "Default Public Gateway", + "message": "Default 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 path gateway serves every request from a single origin, so it works well outside the browser but gives no origin isolation. It is used as a fallback when your local gateway is unavailable, and for shareable links when public sharing is enabled below and Use Subdomains is off. Produces links like https://example.com/ipfs/CID. Leave it empty to copy native ipfs:// and ipns:// URIs.", "description": "An option description on the Preferences screen (option_publicGatewayUrl_description)" }, "option_publicSubdomainGatewayUrl_title": { @@ -352,9 +352,17 @@ "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 subdomain gateway gives each CID, IPNS, or DNSLink its own origin, isolating them the way browsers keep sites apart. It is used to recover broken subdomain links, and for shareable links when public sharing is enabled below and Use Subdomains is on. Produces links like https://CID.ipfs.example.com. Leave it empty to copy native ipfs:// and ipns:// URIs.", "description": "An option description on the Preferences screen (option_publicSubdomainGatewayUrl_description)" }, + "option_usePublicGatewaysForShare_title": { + "message": "Use Public Gateways above when Sharing links", + "description": "An option title on the Preferences screen (option_usePublicGatewaysForShare_title)" + }, + "option_usePublicGatewaysForShare_description": { + "message": "When on, \"Copy Shareable Link\" copies a URL on the public gateways set above, handy for sending IPFS content to people who don't run IPFS. When off, it copies native ipfs:// and ipns:// URIs. Set the gateway URLs above to enable this.", + "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)" @@ -751,6 +759,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..ab88a57e2 100644 --- a/add-on/src/lib/ipfs-companion.js +++ b/add-on/src/lib/ipfs-companion.js @@ -277,19 +277,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 +666,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..a3afd5775 100644 --- a/add-on/src/lib/ipfs-import.js +++ b/add-on/src/lib/ipfs-import.js @@ -14,6 +14,7 @@ export const browserActionFilesCpImportCurrentTab = 'browserActionFilesCpImportC export function createIpfsImportHandler (getState, getIpfs, ipfsPathValidator, runtime, copier) { const { resolveToPublicUrl, + resolveToShareableUrl, resolveToCid } = ipfsPathValidator const ipfsImportHandler = { @@ -23,8 +24,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,7 +69,7 @@ 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) }, diff --git a/add-on/src/lib/ipfs-path.js b/add-on/src/lib/ipfs-path.js index d700431eb..2d2424204 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,70 @@ 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) +} + +// 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 match = contentPath.match(/^\/(ipfs|ipns)\/([^/?#]+)(.*)$/) + if (!match) return null + const [, ns, root, rest] = match + 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}'`) +} + +// 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. +function contentPathToSubdomainUrl (contentPath, gwURL) { + const match = contentPath.match(/^\/(ipfs|ipns)\/([^/?#]+)(.*)$/) + if (!match) return null + const [, ns, root, rest] = match + if (ns === 'ipns' && root.includes('.')) { + return trimDoubleSlashes(new URL(`${gwURL.protocol}//${root}${rest}`).toString()) + } + const label = toCaseInsensitiveRoot(ns, root) + return trimDoubleSlashes(new URL(`${gwURL.protocol}//${label}.${ns}.${gwURL.host}${rest}`).toString()) +} + +// Attach a content path to a public gateway for a shareable link. When both +// public gateways are set, the "Use Subdomains" preference decides which one is +// used (subdomain gateway -> subdomain link, path gateway -> path link); when +// only one is set, that one is used. Returns null when neither is configured. +function publicShareUrl (contentPath, state) { + if (!contentPath) return null + const { useSubdomains, pubGwURLString, pubSubdomainGwURL } = state + if (useSubdomains && pubSubdomainGwURL) return contentPathToSubdomainUrl(contentPath, pubSubdomainGwURL) + if (pubGwURLString) return pathAtHttpGateway(contentPath, pubGwURLString) + if (pubSubdomainGwURL) return contentPathToSubdomainUrl(contentPath, pubSubdomainGwURL) + return null } function subdomainPatternMatch (url) { @@ -104,6 +171,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) } @@ -240,6 +309,8 @@ export function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) { // NATIVE ipns:// with DNSLink requires simple protocol swap if (input.startsWith('ipns://')) { + // no public gateway configured: keep the native ipns:// URI + if (!pubGwURLString) return input const dnslinkUrl = new URL(input) dnslinkUrl.protocol = 'https:' const dnslink = await dnslinkResolver.readAndCacheDnslink(dnslinkUrl.hostname) @@ -251,6 +322,8 @@ export function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) { // SUBDOMAINS // Detect *.dweb.link and other subdomain gateways if (isIPFS.subdomain(input)) { + // no public subdomain gateway configured: copy the native ipfs:// / ipns:// URI + if (!pubSubdomainGwURL) return ipfsUri(input) // Switch Origin to prefered public subdomain gateway (default: dweb.link) const subdomainUrl = swapSubdomainGateway(input, pubSubdomainGwURL) @@ -282,12 +355,39 @@ 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)) { + // no public gateway configured: copy the native ipfs:// / ipns:// URI + if (!pubGwURLString) return ipfsUri(input) + return pathAtHttpGateway(ipfsPath, pubGwURLString) + } // 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": + // native ipfs:// / ipns:// URI by default, or a public gateway URL when the + // user opted in via usePublicGatewaysForShare. With public sharing on, the + // "Use Subdomains" preference picks which configured gateway is used. + async resolveToShareableUrl (urlOrPath) { + const state = getState() + if (!state.usePublicGatewaysForShare) { + return ipfsPathValidator.resolveToNativeUri(urlOrPath) + } + const contentPath = ipfsPathValidator.resolveToIpfsPath(urlOrPath) + return publicShareUrl(contentPath, state) ?? ipfsPathValidator.resolveToNativeUri(urlOrPath) + }, + // 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) @@ -306,10 +406,15 @@ export function createIpfsPathValidator (getState, getIpfs, dnslinkResolver) { // and that never changes. async resolveToPermalink (urlOrPath, optionalGatewayUrl) { const input = urlOrPath + const state = getState() 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 follow the same share preference as + // resolveToShareableUrl (immutable native ipfs:// URI unless public sharing + // is on, in which case "Use Subdomains" picks the gateway) + if (optionalGatewayUrl) return pathAtHttpGateway(ipfsPath, optionalGatewayUrl) + if (!state.usePublicGatewaysForShare) return ipfsUri(ipfsPath) + return publicShareUrl(ipfsPath, state) ?? ipfsUri(ipfsPath) }, // 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..c1a8d9cdc 100644 --- a/add-on/src/lib/ipfs-request.js +++ b/add-on/src/lib/ipfs-request.js @@ -182,7 +182,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 +190,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 +371,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 +434,13 @@ 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 resolveToActiveGatewayUrl(request.url, state, ipfsPathValidator) + // only recover to a loadable http(s) gateway; a tab can't open native ipfs:// + if (redirectUrl && redirectUrl.startsWith('http')) { + 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 +468,12 @@ 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 resolveToActiveGatewayUrl(request.url, state, ipfsPathValidator) + // only recover to a loadable http(s) gateway; a tab can't open native ipfs:// + if (redirectUrl && redirectUrl.startsWith('http')) { + log(`onCompleted: attempting to recover from HTTP Error ${request.statusCode} for ${request.url} → ${redirectUrl}`, request) + return updateTabWithURL(request, redirectUrl, browser) + } } } } @@ -667,6 +673,16 @@ function normalizedUnhandledIpfsProtocol (request, pubGwUrl) { // RECOVERY OF FAILED REQUESTS // =================================================================== +// Resolve a failed request to a loadable gateway URL for recovery: prefer the +// configured public gateway, otherwise the local gateway when it is available. +// Returns null (no recovery) when neither is usable, 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) { + if (state.pubGwURLString) return ipfsPathValidator.resolveToPublicUrl(url) + if (state.localGwAvailable) return ipfsPathValidator.resolveToLocalUrl(url) + 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 diff --git a/add-on/src/lib/options.js b/add-on/src/lib/options.js index 61e1d8f63..fe049a238 100644 --- a/add-on/src/lib/options.js +++ b/add-on/src/lib/options.js @@ -11,8 +11,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: '', + publicSubdomainGatewayUrl: '', + // When off (default), "Copy Shareable Link" copies native ipfs:// and ipns:// + // URIs. Turn it on to copy public gateway URLs instead (needs the two public + // gateway URLs above to be set). + usePublicGatewaysForShare: false, useCustomGateway: true, useSubdomains: true, enabledOn: [], // hostnames with explicit integration opt-in @@ -93,8 +97,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`