diff --git a/public/locales/en/app.json b/public/locales/en/app.json index 7a1a35798..fc3a36031 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -46,13 +46,13 @@ "placeholder": "Enter a URL (http://user:password@127.0.0.1:5001) or a Multiaddr (/ip4/127.0.0.1/tcp/5001)" }, "publicGatewayForm": { - "placeholder": "Enter a URL (https://ipfs.io)" + "placeholder": "Enter an HTTP URL (https://path-gw.example.com)" }, "localGatewayForm": { - "placeholder": "Enter a URL (http://localhost:8080)" + "placeholder": "Enter an HTTP URL (http://localhost:8080)" }, "publicSubdomainGatewayForm": { - "placeholder": "Enter a URL (https://dweb.link)" + "placeholder": "Enter an HTTP URL (https://subdomain-gw.example.net)" }, "ipfsCheckForm": { "label": "Retrieval Check Service URL", @@ -90,7 +90,8 @@ "pinStatus": "Pin Status", "publicKey": "Public key", "publicGateway": "Public Gateway", - "localGateway": "Local Gateway", + "publicSubdomainGateway": "Public Subdomain Gateway", + "localGateway": "Local HTTP Gateway", "rateIn": "Rate in", "rateOut": "Rate out", "repo": "Repo", diff --git a/public/locales/en/files.json b/public/locales/en/files.json index dd96da470..ad9d9ea4b 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -7,6 +7,7 @@ "individualFilesOnly": "Only available for individual files", "noPDFSupport": "Your browser does not support PDFs. Please download the PDF to view it:", "downloadPDF": "Download PDF", + "openWithLocalGateway": "Try opening it instead with your <1>local gateway.", "openWithPublicGateway": "Try opening it instead with your <1>public gateway.", "openWithLocalAndPublicGateway": "Try opening it instead with your <1>local gateway or <3>public gateway.", "cantBePreviewed": "Sorry, this file can’t be previewed", @@ -28,7 +29,12 @@ }, "shareModal": { "title": "Share files", - "description": "Copy the link below and share it with your friends." + "description": "Copy the link below and share it with your friends.", + "descriptionLocal": "Use this link to open in apps running on this machine.", + "descriptionNative": "This native IPFS address opens in apps and browsers that support IPFS, like the <1>IPFS Companion extension.", + "useLocalLink": "Local HTTP link for other apps on this machine", + "useSubdomains": "Use localhost subdomains for web apps", + "linkError": "Could not generate the link. Check that your IPFS node is running." }, "renameModal": { "titleFile": "Rename file", diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 9c320f9cd..ff5d7639d 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -24,8 +24,39 @@ }, "apiDescription": "<0>If your node is configured with a <1>custom Kubo RPC API address, including a port other than the default 5001, enter it here.", "localGatewayDescription": "<0>If you access the WebUI through a reverse proxy, Docker, or a different host, enter the gateway URL your browser can reach. Leave empty to use the first <1>gateway address from your Kubo config.", - "publicSubdomainGatewayDescription": "<0>Select a default <1>Subdomain Gateway for generating shareable links.", - "publicPathGatewayDescription": "<0>Select a fallback <1>Path Gateway for generating shareable links for CIDs that exceed the 63-character DNS limit.", + "shareLink": { + "title": "Sharing IPFS Links", + "intro": "This controls the link you copy with Share Link or Publish to IPNS. Native addresses are the default. The gateway options below copy a regular HTTP link instead, each for a specific need, so pick one only when you know you want it.", + "pathVsSubdomain": "Gateway links come in two shapes: <0>path and <1>subdomain. A path link puts everything from that gateway on one origin, the boundary your browser uses for cache, cookies, and saved logins, so one page can see what another left behind. A subdomain link gives each item its own origin, so your browser keeps them apart. Use a subdomain link for opening or hosting web pages, and a path link for non-browser apps.", + "recommendedBadge": "Recommended", + "gatewayGroupTitle": "HTTP gateway links", + "gatewayGroupNote": "These open in any HTTP client, but depend on a specific URL being reachable.", + "usesGateway": "Produces links like", + "subdomainNote": "Each item gets its own origin. Learn <0>how browsers keep sites apart.", + "publicGatewayHelp": "New to public gateways? Find one with the <0>Public Gateway Checker, or <1>run your own.", + "publicGatewayEmptyHint": "To use this, enter a gateway address below.", + "native": { + "label": "Native (ipfs:// and ipns:// addresses)", + "description": "These addresses don't depend on any HTTP server, so they keep working even when a public gateway is down. Best for sharing with people peer-to-peer.", + "companionNote": "To open them, you and the people you share with can install the <0>IPFS Companion browser extension. Everyone then loads content directly from each other, instead of depending on someone else's HTTP server." + }, + "localPath": { + "label": "Local gateway, path link", + "description": "Opens content straight from your node on this computer. Good for local apps that aren't a browser. Does not work for sharing with other people." + }, + "localSubdomain": { + "label": "Local gateway, subdomain link", + "description": "Like the option above, but for web apps on this computer that need origin isolation for security. Does not work for sharing with other people. Needs a gateway on localhost, not a raw IP." + }, + "publicPath": { + "label": "Public gateway, path link", + "description": "A link anyone on the internet can open through a public gateway. Without origin isolation, don't use it for sensitive web apps." + }, + "publicSubdomain": { + "label": "Public gateway, subdomain link", + "description": "Like the option above, but the safer choice for opening or hosting web pages and apps that require origin isolation." + } + }, "retrievalDiagnosticService": { "title": "Retrieval Diagnostic Service", "description": "Configure the URL of the <0>ipfs-check service used for <1>retrieval diagnostics. This service checks if content can be successfully fetched from your node and other nodes hosting a specific CID, helping you troubleshoot sharing issues." diff --git a/public/locales/en/welcome.json b/public/locales/en/welcome.json index 571e0d64e..3636d8d77 100644 --- a/public/locales/en/welcome.json +++ b/public/locales/en/welcome.json @@ -14,7 +14,7 @@ "header": "What is IPFS?", "paragraph1": "<0><0>A hypermedia distribution protocol that incorporates ideas from Kademlia, BitTorrent, Git, and more", "paragraph2": "<0><0>A peer-to-peer file transfer network with a completely decentralized architecture and no central point of failure, censorship, or control", - "paragraph3": "<0><0>An on-ramp to tomorrow's web — traditional browsers can access IPFS files through gateways like <2>https://dweb.link or directly using the <4>IPFS Companion extension", + "paragraph3": "<0><0>An on-ramp to tomorrow's web: traditional browsers can access IPFS content through an HTTP gateway, or directly with the <2>IPFS Companion extension", "paragraph4": "<0><0>A next-gen CDN — just add a file to your node to make it available to the world with cache-friendly content-hash addressing and BitTorrent-style bandwidth distribution", "paragraph5": "<0><0>A developer toolset for building <2>completely distributed apps and services, backed by a robust open-source community" }, diff --git a/src/bundles/config.js b/src/bundles/config.js index 049a54367..e9a5aa506 100644 --- a/src/bundles/config.js +++ b/src/bundles/config.js @@ -1,10 +1,7 @@ -import memoize from 'p-memoize' import { multiaddrToUri as toUri } from '@multiformats/multiaddr-to-uri' import { createAsyncResourceBundle, createSelector } from 'redux-bundler' import { contextBridge } from '../helpers/context-bridge' -const LOCAL_HOSTNAMES = ['127.0.0.1', '[::1]', '0.0.0.0', '[::]'] - const bundle = createAsyncResourceBundle({ name: 'config', staleAfter: 60000, @@ -23,36 +20,15 @@ const bundle = createAsyncResourceBundle({ const config = JSON.parse(conf) - // An explicit Local Gateway URL is the gateway the browser is meant to reach - // for everything (reverse proxy, Docker, non-default host or port), so trust - // it for the reachability-probed availableGateway too. Without this, the - // 127.0.0.1 probe below fails for remote users and previews, thumbnails and - // IPNS links fall back to the public gateway instead of the user's own node. + // availableGateway drives previews, thumbnails, IPNS links and the Explore + // link: the user's Local Gateway URL override, or the gateway from the Kubo + // config, chosen without any reachability probing. selectAvailableGatewayUrl + // only falls back to a public gateway (through selectGatewayUrl) when no + // local gateway is configured at all. // https://github.com/ipfs/ipfs-webui/issues/2458 - const localGateway = store.selectLocalGateway() - if (localGateway) { - store.doSetAvailableGateway(localGateway) - return conf - } - - const publicGateway = store.selectPublicGateway() - const url = getURLFromAddress('Gateway', config) || publicGateway - - // Normalize local hostnames to localhost - // to leverage subdomain gateway, if present - // https://github.com/ipfs-shipyard/ipfs-webui/issues/1490 - const gw = new URL(url) - if (LOCAL_HOSTNAMES.includes(gw.hostname)) { - gw.hostname = 'localhost' - const localUrl = gw.toString().replace(/\/+$/, '') // no trailing slashes - if (await checkIfSubdomainGatewayUrlIsAccessible(localUrl)) { - store.doSetAvailableGateway(localUrl) - return conf - } - } - - if (!await checkIfGatewayUrlIsAccessible(url)) { - store.doSetAvailableGateway(publicGateway) + const gateway = store.selectLocalGateway() || getURLFromAddress('Gateway', config) + if (gateway) { + store.doSetAvailableGateway(gateway) } // stringy json for quick compares @@ -75,18 +51,26 @@ bundle.reactIsSameOriginToBridge = createSelector( } ) -bundle.selectGatewayUrl = createSelector( +bundle.selectLocalGatewayUrl = createSelector( 'selectConfigObject', - 'selectPublicGateway', 'selectLocalGateway', - (config, publicGateway, localGateway) => { - // Priority: 1) User-configured local gateway, 2) Kubo config, 3) Public gateway - const url = localGateway || getURLFromAddress('Gateway', config) || publicGateway + (config, localGateway) => { + // Priority: 1) User-configured local gateway, 2) Kubo config. Never the + // public gateway: consumers use this where "local" is a promise (share + // links and content links labeled as same-machine). + const url = localGateway || getURLFromAddress('Gateway', config) || '' // Normalize: remove trailing slashes to avoid double slashes when constructing paths return url.replace(/\/+$/, '') } ) +bundle.selectGatewayUrl = createSelector( + 'selectLocalGatewayUrl', + 'selectPublicGateway', + (localGatewayUrl, publicGateway) => + localGatewayUrl || publicGateway.replace(/\/+$/, '') +) + bundle.selectAvailableGatewayUrl = createSelector( 'selectAvailableGateway', 'selectGatewayUrl', @@ -127,31 +111,4 @@ function getURLFromAddress (name, config) { } } -const checkIfGatewayUrlIsAccessible = memoize(async (url) => { - try { - const { status } = await fetch( - `${url}/ipfs/bafkqae2xmvwgg33nmuqhi3zajfiemuzahiwss` - ) - return status === 200 - } catch (e) { - console.error(`Unable to use the gateway at ${url}. The public gateway will be used as a fallback`, e) - return false - } -}) - -// Separate test is necessary to see if subdomain mode is possible, -// because some browser+OS combinations won't resolve them: -// https://github.com/ipfs/kubo/issues/7527 -const checkIfSubdomainGatewayUrlIsAccessible = memoize(async (url) => { - try { - url = new URL(url) - url.hostname = `bafkqae2xmvwgg33nmuqhi3zajfiemuzahiwss.ipfs.${url.hostname}` - const { status } = await fetch(url.toString()) - return status === 200 - } catch (e) { - console.error(`Unable to use the subdomain gateway at ${url}. Regular gateway will be used as a fallback`, e) - return false - } -}) - export default bundle diff --git a/src/bundles/files/actions.js b/src/bundles/files/actions.js index 44bca1289..ff4e25047 100644 --- a/src/bundles/files/actions.js +++ b/src/bundles/files/actions.js @@ -1,7 +1,8 @@ /* eslint-disable require-yield */ import { join, dirname, basename } from 'path' -import { getDownloadLink, getShareableLink, getCarLink } from '../../lib/files.js' +import { getDownloadLink, getCarLink, resolveShareCid } from '../../lib/files.js' +import { buildShareLink, getFilenameQuery, getLocalLinks } from '../../lib/share-link.js' import countDirs from '../../lib/count-dirs.js' import memoize from 'p-memoize' import all from 'it-all' @@ -153,8 +154,10 @@ const getPinCIDs = (ipfs) => map(getRawPins(ipfs), (pin) => pin.cid) * @typedef {Object} ConfigSelectors * @property {function():string} selectApiUrl * @property {function():string} selectGatewayUrl + * @property {function():string} selectLocalGatewayUrl * @property {function():string} selectPublicGateway * @property {function():string} selectPublicSubdomainGateway + * @property {function():string} selectEffectiveShareLinkType * * @typedef {Object} UnkonwActions * @property {function(string):Promise} doUpdateHash @@ -598,14 +601,35 @@ const actions = () => ({ doFilesShareLink: (/** @type {FileStat[]} */ files) => perform(ACTIONS.SHARE_LINK, async (ipfs, { store }) => { // ensureMFS deliberately omitted here, see https://github.com/ipfs/ipfs-webui/issues/1744 for context. + const cid = await resolveShareCid(files, ipfs) + const filename = getFilenameQuery(files) + // Local-only gateway (user override or Kubo config): a link labeled local + // must never point at the public gateway. + const localGatewayUrl = store.selectLocalGatewayUrl() const publicGateway = store.selectPublicGateway() const publicSubdomainGateway = store.selectPublicSubdomainGateway() - const { link: shareableLink, cid } = await getShareableLink(files, publicGateway, publicSubdomainGateway, ipfs) - // Trigger background provide operation with the CID from getShareableLink + // The effective type already falls back to native when the chosen type's + // gateway is not configured, so buildShareLink always returns a link here. + const type = store.selectEffectiveShareLinkType() + const link = buildShareLink({ + type, + namespace: 'ipfs', + pathId: cid.toString(), + subdomainLabel: cid.toV1().toString(), + filename, + localGatewayUrl, + publicGateway, + publicSubdomainGateway + }) + + // Local variants for the in-modal override, offered when the chosen type is + // native or public so users can still grab a same-machine link. + const { localLink, subdomainLocalLink } = getLocalLinks(cid, filename, localGatewayUrl) + dispatchAsyncProvide(cid, ipfs) - return shareableLink + return { link, type, localLink, subdomainLocalLink } }), /** diff --git a/src/bundles/files/protocol.ts b/src/bundles/files/protocol.ts index 90c9f450b..bb4a39937 100644 --- a/src/bundles/files/protocol.ts +++ b/src/bundles/files/protocol.ts @@ -78,6 +78,13 @@ export type FileDownload = { filename: string } export type DownloadLink = Perform<'FILES_DOWNLOADLINK', Error, FileDownload, void> +export type ShareLinks = { + link: string + type: string + localLink: string + subdomainLocalLink: string +} +export type ShareLink = Perform<'FILES_SHARE_LINK', Error, ShareLinks, void> export type Message = | { type: 'FILES_CLEAR_ALL' } @@ -91,7 +98,7 @@ export type Message = | AddByPath | BulkCidImport | DownloadLink - | Perform<'FILES_SHARE_LINK', Error, string, void> + | ShareLink | Perform<'FILES_COPY', Error, void, void> | Perform<'FILES_PIN_ADD', Error, Pin[], void> | Perform<'FILES_PIN_REMOVE', Error, Pin[], void> diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js index d10166e18..9d51ce54a 100644 --- a/src/bundles/gateway.js +++ b/src/bundles/gateway.js @@ -1,22 +1,17 @@ +import { createSelector } from 'redux-bundler' import { readSetting, writeSetting } from './local-storage.js' +import { SHARE_LINK_TYPE, DEFAULT_SHARE_LINK_TYPE, resolveEffectiveShareLinkType } from '../lib/share-link.js' -// TODO: switch to dweb.link when https://github.com/ipfs/kubo/issues/7318 -export const DEFAULT_PATH_GATEWAY = 'https://ipfs.io' -export const DEFAULT_SUBDOMAIN_GATEWAY = 'https://dweb.link' export const DEFAULT_IPFS_CHECK_URL = 'https://check.ipfs.network' -// Test URLs that bypass validation for e2e tests +// Test gateway URLs used by e2e tests export const TEST_PATH_GATEWAY = 'https://e2e-test-path-gateway.test' export const TEST_SUBDOMAIN_GATEWAY = 'https://e2e-test-subdomain-gateway.test' -const IMG_HASH_1PX = 'bafkreib6wedzfupqy7qh44sie42ub4mvfwnfukmw6s2564flajwnt4cvc4' // 1x1.png -const IMG_ARRAY = [ - { id: 'IMG_HASH_1PX', name: '1x1.png', hash: IMG_HASH_1PX }, - { id: 'IMG_HASH_1PXID', name: '1x1.png', hash: 'bafkqax4jkbheodikdifaaaaabveuqrcsaaaaaaiaaaaacaidaaaaajo3k3faaaaaanieyvcfaaaabj32hxnaaaaaaf2fetstabaonwdgaaaaacsjiravicgxmnqaaaaaaiaadyrbxqzqaaaaabeuktsevzbgbaq' }, - { id: 'IMG_HASH_FAVICON', name: 'favicon.ico', hash: 'bafkreihc7efnl2prri6j6krcopelxms3xsh7undpsjqbfsasm7ikiyha4i' } -] +// Public gateways start empty until the user opts in, so a fresh node shares +// native ipfs:// links rather than routing through a third-party gateway. const readPublicGatewaySetting = () => { const setting = readSetting('ipfsPublicGateway') - return setting || DEFAULT_PATH_GATEWAY + return typeof setting === 'string' ? setting : '' } const readLocalGatewaySetting = () => { @@ -28,7 +23,7 @@ const readLocalGatewaySetting = () => { const readPublicSubdomainGatewaySetting = () => { const setting = readSetting('ipfsPublicSubdomainGateway') - return setting || DEFAULT_SUBDOMAIN_GATEWAY + return typeof setting === 'string' ? setting : '' } const readIpfsCheckUrlSetting = () => { @@ -36,12 +31,24 @@ const readIpfsCheckUrlSetting = () => { return setting || DEFAULT_IPFS_CHECK_URL } +const readShareLinkTypeSetting = () => { + const setting = readSetting('ipfsShareLinkType') + return typeof setting === 'string' && Object.values(SHARE_LINK_TYPE).includes(setting) + ? setting + : DEFAULT_SHARE_LINK_TYPE +} + const init = () => ({ availableGateway: null, publicGateway: readPublicGatewaySetting(), publicSubdomainGateway: readPublicSubdomainGatewaySetting(), ipfsCheckUrl: readIpfsCheckUrlSetting(), - localGateway: readLocalGatewaySetting() + localGateway: readLocalGatewaySetting(), + shareLinkType: readShareLinkTypeSetting(), + // Not persisted: set when the local gateway changes so the Explore page + // reloads its Helia node (which reads kuboGateway only at startup) the next + // time it is opened. A page reload resets this back to false. + explorerNeedsReload: false }) /** @@ -84,139 +91,6 @@ export const localGatewayToKuboGateway = (gatewayUrl) => { } } -/** - * Check if any hashes from IMG_ARRAY can be loaded from the provided gatewayUrl - * @param {string} gatewayUrl - The gateway URL to check - * @see https://github.com/ipfs/ipfs-webui/issues/1937#issuecomment-1152894211 for more info - */ -export const checkViaImgSrc = (gatewayUrl) => { - // Skip validation for test gateways - if (gatewayUrl === TEST_PATH_GATEWAY) { - return Promise.resolve() - } - - const url = new URL(gatewayUrl) - - /** - * we check if gateway is up by loading 1x1 px image: - * this is more robust check than loading js, as it won't be blocked - * by privacy protections present in modern browsers or in extensions such as Privacy Badger - */ - // @ts-expect-error - Promise.any requires ES2021 but we're on ES2020 - return Promise.any(IMG_ARRAY.map(element => { - // url.host (not hostname) keeps the port, so the probe also works for local - // gateways on non-default ports, e.g. http://127.0.0.1:8080. - const imgUrl = new URL(`${url.protocol}//${url.host}/ipfs/${element.hash}?now=${Date.now()}&filename=${element.name}#x-ipfs-companion-no-redirect`) - return checkImgSrcPromise(imgUrl) - })) -} - -/** - * @param {URL} imgUrl - The image URL to check - * @returns {Promise} - */ -const checkImgSrcPromise = (imgUrl) => { - const imgCheckTimeout = 15000 - - return new Promise((resolve, reject) => { - const timeout = () => { - if (!timer) return false - clearTimeout(timer) - timer = null - return true - } - - /** @type {NodeJS.Timeout | null} */ - let timer = setTimeout(() => { if (timeout()) reject(new Error(`Image load timed out after ${imgCheckTimeout / 1000} seconds for URL: ${imgUrl}`)) }, imgCheckTimeout) - const img = new Image() - - img.onerror = () => { - timeout() - reject(new Error(`Failed to load image from URL: ${imgUrl}`)) - } - - img.onload = () => { - // subdomain works - timeout() - resolve() - } - - img.src = imgUrl.toString() - }) -} - -/** - * Checks if a given URL redirects to a subdomain that starts with a specific hash. - * - * @param {URL} url - The URL to check for redirection. - * @throws {Error} Throws an error if the URL does not redirect to the expected subdomain. - * @returns {Promise} A promise that resolves if the URL redirects correctly, otherwise it throws an error. - */ -async function expectSubdomainRedirect (url) { - // Detecting redirects on remote Origins is extra tricky, - // but we seem to be able to access xhr.responseURL which is enough to see - // if paths are redirected to subdomains. - - const { url: responseUrl } = await fetch(url.toString()) - const { hostname } = new URL(responseUrl) - - if (!hostname.startsWith(IMG_HASH_1PX)) { - const msg = `Expected ${url.toString()} to redirect to subdomain '${IMG_HASH_1PX}' but instead received '${responseUrl}'` - console.error(msg) - throw new Error(msg) - } -} - -/** - * Checks if an image can be loaded from a given URL within a specified timeout. - * - * @param {URL} imgUrl - The URL of the image to be loaded. - * @returns {Promise} A promise that resolves if the image loads successfully within the timeout, otherwise it rejects with an error. - */ -async function checkViaImgUrl (imgUrl) { - try { - await checkImgSrcPromise(imgUrl) - } catch (error) { - throw new Error(`Error or timeout when attempting to load img from '${imgUrl.toString()}'`) - } -} - -/** - * Checks if a given gateway URL is functioning correctly by verifying image loading and redirection. - * - * @param {string} gatewayUrl - The URL of the gateway to be checked. - * @returns {Promise} A promise that resolves to true if the gateway is functioning correctly, otherwise false. - */ -export async function checkSubdomainGateway (gatewayUrl) { - if (gatewayUrl === DEFAULT_SUBDOMAIN_GATEWAY || gatewayUrl === TEST_SUBDOMAIN_GATEWAY) { - // avoid sending probe requests to the default gateway every time Settings page is opened - // also skip validation for test gateways - return true - } - /** @type {URL} */ - let imgSubdomainUrl - /** @type {URL} */ - let imgRedirectedPathUrl - try { - const gwUrl = new URL(gatewayUrl) - imgSubdomainUrl = new URL(`${gwUrl.protocol}//${IMG_HASH_1PX}.ipfs.${gwUrl.hostname}/?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`) - imgRedirectedPathUrl = new URL(`${gwUrl.protocol}//${gwUrl.hostname}/ipfs/${IMG_HASH_1PX}?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`) - } catch (err) { - console.error('Invalid URL:', err) - return false - } - return await checkViaImgUrl(imgSubdomainUrl) - .then(async () => expectSubdomainRedirect(imgRedirectedPathUrl)) - .then(() => { - console.log(`Gateway at '${gatewayUrl}' is functioning correctly (verified image loading and redirection)`) - return true - }) - .catch((err) => { - console.error(err) - return false - }) -} - const bundle = { name: 'gateway', @@ -246,6 +120,14 @@ const bundle = { return { ...state, localGateway: action.payload } } + if (action.type === 'SET_SHARE_LINK_TYPE') { + return { ...state, shareLinkType: action.payload } + } + + if (action.type === 'SET_EXPLORER_NEEDS_RELOAD') { + return { ...state, explorerNeedsReload: action.payload } + } + return state }, @@ -257,20 +139,28 @@ const bundle = { /** * @param {string} address - * @returns {function({dispatch: Function}): Promise} + * @returns {function({dispatch: Function, store: any}): Promise} */ - doUpdatePublicGateway: (address) => async ({ dispatch }) => { + doUpdatePublicGateway: (address) => async ({ dispatch, store }) => { await writeSetting('ipfsPublicGateway', address) dispatch({ type: 'SET_PUBLIC_GATEWAY', payload: address }) + // Clearing the gateway that the Share Link type points at reverts the choice + // to native, so the selected option matches what is actually configured. + if (!address && store.selectShareLinkType() === SHARE_LINK_TYPE.PUBLIC_PATH) { + await store.doUpdateShareLinkType(SHARE_LINK_TYPE.NATIVE) + } }, /** * @param {string} address - * @returns {function({dispatch: Function}): Promise} + * @returns {function({dispatch: Function, store: any}): Promise} */ - doUpdatePublicSubdomainGateway: (address) => async ({ dispatch }) => { + doUpdatePublicSubdomainGateway: (address) => async ({ dispatch, store }) => { await writeSetting('ipfsPublicSubdomainGateway', address) dispatch({ type: 'SET_PUBLIC_SUBDOMAIN_GATEWAY', payload: address }) + if (!address && store.selectShareLinkType() === SHARE_LINK_TYPE.PUBLIC_SUBDOMAIN) { + await store.doUpdateShareLinkType(SHARE_LINK_TYPE.NATIVE) + } }, /** @@ -284,14 +174,23 @@ const bundle = { /** * @param {string} address - * @returns {function({dispatch: Function}): Promise} + * @returns {function({dispatch: Function, store: any}): Promise} */ - doUpdateLocalGateway: (address) => async ({ dispatch }) => { + doUpdateLocalGateway: (address) => async ({ dispatch, store }) => { // Normalize: remove trailing slashes const normalizedAddress = address.replace(/\/+$/, '') + // Re-saving the same value must not rewrite settings or schedule a + // spurious Explore reload. + if (normalizedAddress === store.selectLocalGateway()) return await writeSetting('ipfsLocalGateway', normalizedAddress) dispatch({ type: 'SET_LOCAL_GATEWAY', payload: normalizedAddress }) + // Refresh availableGateway now (the override when set, the Kubo config + // gateway when cleared) so previews, thumbnails, IPNS links and the Explore + // link switch immediately, instead of waiting for the config bundle to go + // stale. + store.doSetAvailableGateway(store.selectGatewayUrl()) + // Keep kuboGateway (used by Helia/Explore) in sync with the override. if (normalizedAddress) { try { @@ -303,6 +202,11 @@ const bundle = { // Override cleared: restore defaults so Explore stops using the old host. await writeSetting('kuboGateway', DEFAULT_KUBO_GATEWAY) } + + // The Explore page's Helia node reads kuboGateway only when it boots, so it + // cannot pick up the change in place. Flag it to reload the next time the + // user opens Explore; that reload clears this (non-persisted) flag. + dispatch({ type: 'SET_EXPLORER_NEEDS_RELOAD', payload: true }) }, /** @@ -333,7 +237,45 @@ const bundle = { * @param {any} state * @returns {string} */ - selectLocalGateway: (state) => state?.gateway?.localGateway + selectLocalGateway: (state) => state?.gateway?.localGateway, + + /** + * Whether the Explore page should reload to re-init its Helia node after a + * local gateway change. See ExploreContainer. + * @param {any} state + * @returns {boolean} + */ + selectExplorerNeedsReload: (state) => state?.gateway?.explorerNeedsReload, + + /** + * @param {string} type - a SHARE_LINK_TYPE value + * @returns {function({dispatch: Function}): Promise} + */ + doUpdateShareLinkType: (type) => async ({ dispatch }) => { + await writeSetting('ipfsShareLinkType', type) + dispatch({ type: 'SET_SHARE_LINK_TYPE', payload: type }) + }, + + /** + * The link type the user selected for Share Link and Publish to IPNS. + * @param {any} state + * @returns {string} + */ + selectShareLinkType: (state) => state?.gateway?.shareLinkType, + + // The link type actually used: a type whose gateway is not configured (a + // public type with its gateway cleared, or a local type with no local + // gateway) falls back to native, so the two consumers (Share Link, Publish + // to IPNS) stay consistent with the disabled-option logic in Settings and + // buildShareLink always produces a link for this type. + selectEffectiveShareLinkType: createSelector( + 'selectShareLinkType', + 'selectPublicGateway', + 'selectPublicSubdomainGateway', + 'selectLocalGatewayUrl', + (shareLinkType, publicGateway, publicSubdomainGateway, localGatewayUrl) => + resolveEffectiveShareLinkType(shareLinkType, { publicGateway, publicSubdomainGateway, localGatewayUrl }) + ) } export default bundle diff --git a/src/bundles/gateway.test.js b/src/bundles/gateway.test.js index 66c121035..d2222727f 100644 --- a/src/bundles/gateway.test.js +++ b/src/bundles/gateway.test.js @@ -1,5 +1,9 @@ -/* global describe, it, expect */ -import { localGatewayToKuboGateway, checkValidHttpUrl } from './gateway.js' +/* global describe, it, expect, beforeEach */ +import { composeBundles } from 'redux-bundler' +import gatewayBundle, { localGatewayToKuboGateway, checkValidHttpUrl, DEFAULT_KUBO_GATEWAY } from './gateway.js' +import configBundle from './config.js' +import { readSetting } from './local-storage.js' +import { SHARE_LINK_TYPE, DEFAULT_SHARE_LINK_TYPE } from '../lib/share-link.js' describe('localGatewayToKuboGateway', () => { it('keeps an explicit port and treats http as insecure', () => { @@ -41,3 +45,103 @@ describe('checkValidHttpUrl', () => { expect(checkValidHttpUrl('')).toBe(false) }) }) + +const createMockIpfsBundle = (config) => ({ + name: 'ipfs', + getExtraArgs: () => ({ getIpfs: () => ({ config: { getAll: async () => config } }) }), + selectIpfsReady: () => false, + selectIpfsConnected: () => false +}) + +const createStore = () => composeBundles( + createMockIpfsBundle({ Addresses: { Gateway: '/ip4/127.0.0.1/tcp/8080' } }), + gatewayBundle, + configBundle +)() + +describe('gateway bundle actions', () => { + beforeEach(() => window.localStorage.clear()) + + it('reverts the share link type to native when its public path gateway is cleared', async () => { + const store = createStore() + await store.doUpdatePublicGateway('https://path-gw.example.com') + await store.doUpdateShareLinkType(SHARE_LINK_TYPE.PUBLIC_PATH) + expect(store.selectShareLinkType()).toBe(SHARE_LINK_TYPE.PUBLIC_PATH) + await store.doUpdatePublicGateway('') + expect(store.selectShareLinkType()).toBe(SHARE_LINK_TYPE.NATIVE) + }) + + it('does not revert when clearing a public gateway the type does not point at', async () => { + const store = createStore() + await store.doUpdatePublicSubdomainGateway('https://subdomain-gw.example.net') + await store.doUpdateShareLinkType(SHARE_LINK_TYPE.PUBLIC_SUBDOMAIN) + await store.doUpdatePublicGateway('') // clears the PATH gateway; type is SUBDOMAIN + expect(store.selectShareLinkType()).toBe(SHARE_LINK_TYPE.PUBLIC_SUBDOMAIN) + }) + + it('does not revert when saving a non-empty public gateway', async () => { + const store = createStore() + await store.doUpdatePublicGateway('https://a.example.com') + await store.doUpdateShareLinkType(SHARE_LINK_TYPE.PUBLIC_PATH) + await store.doUpdatePublicGateway('https://b.example.com') + expect(store.selectShareLinkType()).toBe(SHARE_LINK_TYPE.PUBLIC_PATH) + }) + + it('doUpdateLocalGateway refreshes availableGateway, syncs kuboGateway, and flags the explorer reload', async () => { + const store = createStore() + await store.doUpdateLocalGateway('http://127.0.0.1:9999') + expect(store.selectExplorerNeedsReload()).toBe(true) + expect(store.selectAvailableGateway()).toBe('http://127.0.0.1:9999') + expect(readSetting('kuboGateway')).toEqual(localGatewayToKuboGateway('http://127.0.0.1:9999')) + + await store.doUpdateLocalGateway('') + expect(readSetting('kuboGateway')).toEqual(DEFAULT_KUBO_GATEWAY) + }) + + it('normalizes a trailing slash on the stored local gateway', async () => { + const store = createStore() + await store.doUpdateLocalGateway('http://127.0.0.1:9999/') + expect(store.selectLocalGateway()).toBe('http://127.0.0.1:9999') + }) + + it('doUpdateLocalGateway is a no-op when the value is unchanged', async () => { + const store = createStore() + // Initial value is '': re-submitting it must not write settings or + // schedule an Explore reload. + await store.doUpdateLocalGateway('') + expect(store.selectExplorerNeedsReload()).toBe(false) + expect(readSetting('kuboGateway')).toBeFalsy() + }) + + it('selectEffectiveShareLinkType falls back to native for a local type with no local gateway', async () => { + const store = createStore() + await store.doUpdateShareLinkType(SHARE_LINK_TYPE.LOCAL_PATH) + // The test store never fetches the Kubo config, so there is no local + // gateway at all until the override is set. + expect(store.selectEffectiveShareLinkType()).toBe(SHARE_LINK_TYPE.NATIVE) + await store.doUpdateLocalGateway('http://127.0.0.1:9999') + expect(store.selectEffectiveShareLinkType()).toBe(SHARE_LINK_TYPE.LOCAL_PATH) + }) + + it('selectEffectiveShareLinkType stays native until the chosen public gateway is set', async () => { + const store = createStore() + await store.doUpdateShareLinkType(SHARE_LINK_TYPE.PUBLIC_SUBDOMAIN) + expect(store.selectEffectiveShareLinkType()).toBe(SHARE_LINK_TYPE.NATIVE) + await store.doUpdatePublicSubdomainGateway('https://subdomain-gw.example.net') + expect(store.selectEffectiveShareLinkType()).toBe(SHARE_LINK_TYPE.PUBLIC_SUBDOMAIN) + }) +}) + +describe('readShareLinkTypeSetting (via bundle init)', () => { + beforeEach(() => window.localStorage.clear()) + + it('falls back to the default for a corrupted stored value', () => { + window.localStorage.setItem('ipfsShareLinkType', JSON.stringify('bogus')) + expect(createStore().selectShareLinkType()).toBe(DEFAULT_SHARE_LINK_TYPE) + }) + + it('preserves a valid stored value', () => { + window.localStorage.setItem('ipfsShareLinkType', JSON.stringify(SHARE_LINK_TYPE.LOCAL_PATH)) + expect(createStore().selectShareLinkType()).toBe(SHARE_LINK_TYPE.LOCAL_PATH) + }) +}) diff --git a/src/bundles/pinning.js b/src/bundles/pinning.js index 6f323f0f2..25a7a2046 100644 --- a/src/bundles/pinning.js +++ b/src/bundles/pinning.js @@ -1,10 +1,12 @@ // @ts-check +import { createSelector } from 'redux-bundler' import { pinningServiceTemplates } from '../constants/pinning.js' import memoize from 'p-memoize' import { CID } from 'multiformats/cid' import all from 'it-all' import { readSetting, writeSetting } from './local-storage.js' +import { toLoopbackIpUrl } from '../lib/share-link.js' import { dispatchAsyncProvide } from './files/utils.js' // This bundle leverages createCacheBundle and persistActions for @@ -38,11 +40,11 @@ const getRerenderAwareArray = (oldArray, newArray) => { return diff.length > 0 ? newArray : oldArray } -const parseService = async (service, remoteServiceTemplates, ipfs) => { - const template = remoteServiceTemplates.find(t => service.endpoint.toString() === t.apiEndpoint.toString()) - const icon = template?.icon - const visitServiceUrl = template?.visitServiceUrl - const parsedService = { ...service, name: service.service, icon, visitServiceUrl } +// Template-derived fields (icon, visitServiceUrl) are joined in at read time +// by selectPinningServices, so they follow gateway changes instead of freezing +// whatever gateway was configured when the services were fetched. +const parseService = async (service, ipfs) => { + const parsedService = { ...service, name: service.service } if (service?.stat?.status === 'invalid') { return { ...parsedService, numberOfPins: -1, online: false } @@ -298,9 +300,8 @@ const pinningBundle = { dispatch({ type: 'SET_REMOTE_PINNING_SERVICES_AVAILABLE', payload: isPinRemotePresent }) if (!isPinRemotePresent) return null - const remoteServiceTemplates = store.selectRemoteServiceTemplates() const offlineListOfServices = await ipfs.pin.remote.service.ls() - const remoteServices = await Promise.all(offlineListOfServices.map(service => parseService(service, remoteServiceTemplates, ipfs))) + const remoteServices = await Promise.all(offlineListOfServices.map(service => parseService(service, ipfs))) dispatch({ type: 'SET_REMOTE_PINNING_SERVICES', payload: remoteServices }) }, @@ -311,16 +312,39 @@ const pinningBundle = { const isPinRemotePresent = (await ipfs.commands()).Subcommands.find(c => c.Name === 'pin').Subcommands.some(c => c.Name === 'remote') if (!isPinRemotePresent) return null - const remoteServiceTemplates = store.selectRemoteServiceTemplates() const servicesWithStats = await ipfs.pin.remote.service.ls({ stat: true }) - const remoteServices = await Promise.all(servicesWithStats.map(service => parseService(service, remoteServiceTemplates, ipfs))) + const remoteServices = await Promise.all(servicesWithStats.map(service => parseService(service, ipfs))) dispatch({ type: 'SET_REMOTE_PINNING_SERVICES', payload: remoteServices }) }, - selectPinningServices: (state) => state.pinning.pinningServices || [], - - selectRemoteServiceTemplates: () => pinningServiceTemplates, + selectPinningServicesRaw: (state) => state.pinning.pinningServices || [], + + // Fetched services joined with their template's icon and service link at + // read time, so they follow the current gateway instead of freezing the one + // configured when the services were fetched. + selectPinningServices: createSelector( + 'selectPinningServicesRaw', + 'selectRemoteServiceTemplates', + (services, templates) => services.map(service => { + const template = templates.find(t => service.endpoint?.toString() === t.apiEndpoint.toString()) + if (!template) return service + return { ...service, icon: template.icon, visitServiceUrl: template.visitServiceUrl } + }) + ), + + // Resolve each provider icon against the available gateway (the same one file + // previews use), so icons load from the local node instead of a hardcoded + // public gateway. toLoopbackIpUrl keeps a localhost gateway loading over + // 127.0.0.1 to avoid the subdomain-redirect breakage (issue 2246). With no + // gateway configured there is no icon, rather than a broken relative URL + // resolved against the WebUI origin. + selectRemoteServiceTemplates: createSelector('selectAvailableGatewayUrl', (availableGatewayUrl) => + pinningServiceTemplates.map((template) => ({ + ...template, + icon: availableGatewayUrl ? toLoopbackIpUrl(`${availableGatewayUrl}/${template.iconPath}`) : '' + })) + ), selectArePinningServicesSupported: (state) => state.pinning.arePinningServicesSupported, diff --git a/src/components/about-ipfs/AboutIpfs.js b/src/components/about-ipfs/AboutIpfs.js index e7fb064af..20c5681d7 100644 --- a/src/components/about-ipfs/AboutIpfs.js +++ b/src/components/about-ipfs/AboutIpfs.js @@ -18,7 +18,7 @@ export const AboutIpfs = ({ t }) => {
  • A peer-to-peer file transfer network with a completely decentralized architecture and no central point of failure, censorship, or control
  • -
  • An on-ramp to tomorrow's web — traditional browsers can access IPFS files through gateways like https://dweb.link or directly using the IFPS Companion extension
  • +
  • An on-ramp to tomorrow's web: traditional browsers can access IPFS content through an HTTP gateway, or directly with the IPFS Companion extension
  • A next-gen CDN — just add a file to your node to make it available to the world with cache-friendly content-hash addressing and BitTorrent-style bandwidth distribution
  • diff --git a/src/components/api-address-form/api-address-form.tsx b/src/components/api-address-form/api-address-form.tsx index fffaef976..1305de722 100644 --- a/src/components/api-address-form/api-address-form.tsx +++ b/src/components/api-address-form/api-address-form.tsx @@ -68,7 +68,7 @@ const ApiAddressForm: React.FC = ({ checkValidAPIAddress = aria-label={t('terms.apiAddress')} placeholder={t('apiAddressForm.placeholder')} type='text' - className={`w-100 lh-copy monospace f5 pl1 pv1 mb2 charcoal input-reset ba b--black-20 br1 ${showFailState ? 'focus-outline-red b--red-muted' : 'focus-outline-green b--green-muted'}`} + className={`w-100 lh-copy monospace f5 pa2 mb2 charcoal input-reset ba b--black-20 br1 ${showFailState ? 'focus-outline-red b--red-muted' : 'focus-outline-green b--green-muted'}`} onChange={onChange} onKeyDown={onKeyDown} value={value} diff --git a/src/components/gateway-form/GatewayForm.js b/src/components/gateway-form/GatewayForm.js new file mode 100644 index 000000000..7f2a54d4e --- /dev/null +++ b/src/components/gateway-form/GatewayForm.js @@ -0,0 +1,70 @@ +import React, { useState } from 'react' +import Button from '../button/button.tsx' +import { checkValidHttpUrl } from '../../bundles/gateway.js' + +/** + * Text input with Clear/Submit buttons for a gateway URL setting. We validate + * the URL format only and trust the user's choice, so a private, offline, or + * reverse-proxy gateway is not rejected. Empty is valid: it clears the setting + * so the caller's fallback applies (e.g. the Kubo config gateway, or a native + * ipfs:// share link). + */ +const GatewayForm = ({ t, savedValue, onUpdate, inputId, clearButtonId, submitButtonId, ariaLabel, placeholder }) => { + const [value, setValue] = useState(savedValue) + const isValid = value === '' || checkValidHttpUrl(value) + + const onSubmit = (event) => { + event.preventDefault() + if (!isValid) return + onUpdate(value) + } + + const onClear = (event) => { + event.preventDefault() + setValue('') + onUpdate('') + } + + const onKeyPress = (event) => { + if (event.key === 'Enter') { + onSubmit(event) + } + } + + return ( +
    + setValue(event.target.value)} + onKeyPress={onKeyPress} + value={value} + /> +
    + + +
    +
    + ) +} + +export default GatewayForm diff --git a/src/components/ipns-manager/IpnsManager.js b/src/components/ipns-manager/IpnsManager.js index b5538b457..d088a8e78 100644 --- a/src/components/ipns-manager/IpnsManager.js +++ b/src/components/ipns-manager/IpnsManager.js @@ -1,6 +1,7 @@ import React, { Fragment, useState, useRef, useMemo, useEffect } from 'react' import { connect } from 'redux-bundler-react' import { sortByProperty } from '../../lib/sort.js' +import { getLocalContentLink, toIpnsBase36 } from '../../lib/share-link.js' import { AutoSizer, Table, Column, SortDirection } from 'react-virtualized' // Components @@ -53,7 +54,7 @@ const OptionsCell = ({ t, name, showRenameKeyModal, showRemoveKeyModal }) => { ) } -export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey, doRenameIpnsKey, doRemoveIpnsKey, availableGatewayUrl, ipnsKeys }) => { +export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey, doRenameIpnsKey, doRemoveIpnsKey, localGatewayUrl, publicGateway, shareLinkType, ipnsKeys }) => { const [isGenerateKeyModalOpen, setGenerateKeyModalOpen] = useState(false) const showGenerateKeyModal = () => setGenerateKeyModalOpen(true) const hideGenerateKeyModal = () => setGenerateKeyModalOpen(false) @@ -81,6 +82,19 @@ export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey, (ipnsKeys || []).sort(sortByProperty(sortSettings.sortBy, sortSettings.sortDirection === SortDirection.ASC ? 1 : -1)), [ipnsKeys, sortSettings.sortBy, sortSettings.sortDirection]) + // Local gateway link honoring the Share Link type from Settings (subdomain + // origin when chosen), falling back to the public gateway, or no link at all. + const ipnsUrl = (id) => { + const ipnsName = toIpnsBase36(id) + return getLocalContentLink({ + shareLinkType, + namespace: 'ipns', + pathId: ipnsName, + subdomainLabel: ipnsName, + localGatewayUrl + }) || (publicGateway ? `${publicGateway}/ipns/${ipnsName}` : '') + } + return (
    @@ -116,11 +130,13 @@ export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey, width={width * 0.6} className='charcoal monospace truncate f6 pl2' flexShrink={1} - cellRenderer={({ rowData }) => ( - rowData.published - ? {rowData.id} + cellRenderer={({ rowData }) => { + if (!rowData.published) return rowData.id + const url = ipnsUrl(rowData.id) + return url + ? {rowData.id} : rowData.id - )} /> + }} /> { - // Empty value is valid: it means "use the gateway address from Kubo config". - const [value, setValue] = useState(localGateway) - const [isValid, setIsValid] = useState(localGateway === '' || checkValidHttpUrl(localGateway)) - const [showFailState, setShowFailState] = useState(!(localGateway === '' || checkValidHttpUrl(localGateway))) - - useEffect(() => { - const valid = value === '' || checkValidHttpUrl(value) - setIsValid(valid) - setShowFailState(!valid) - }, [value]) - - const onChange = (event) => setValue(event.target.value) - - const onSubmit = async (event) => { - event.preventDefault() - if (!isValid) return - // A non-empty override must be reachable from the browser, otherwise it - // would silently break download and preview links with no fallback. - if (value !== '') { - try { - await checkViaImgSrc(value) - } catch (e) { - setShowFailState(true) - return - } - } - doUpdateLocalGateway(value) - } - - const onClear = async (event) => { - event.preventDefault() - setValue('') - doUpdateLocalGateway('') - } - - const onKeyPress = (event) => { - if (event.key === 'Enter') { - onSubmit(event) - } - } - - const hasChanges = value !== localGateway - - return ( -
    - -
    - - -
    -
    - ) -} +import GatewayForm from '../gateway-form/GatewayForm.js' + +// Empty clears the override: the gateway address from the Kubo config applies. +const LocalGatewayForm = ({ t, doUpdateLocalGateway, localGateway }) => ( + +) export default connect( 'doUpdateLocalGateway', diff --git a/src/components/public-gateway-form/PublicGatewayForm.js b/src/components/public-gateway-form/PublicGatewayForm.js index 35a0fff0d..868e86b7b 100644 --- a/src/components/public-gateway-form/PublicGatewayForm.js +++ b/src/components/public-gateway-form/PublicGatewayForm.js @@ -1,89 +1,21 @@ -import React, { useState, useEffect } from 'react' +import React from 'react' import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' -import Button from '../button/button.tsx' -import { checkValidHttpUrl, checkViaImgSrc, DEFAULT_PATH_GATEWAY } from '../../bundles/gateway.js' - -const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => { - const [value, setValue] = useState(publicGateway) - const initialIsValidGatewayUrl = !checkValidHttpUrl(value) - const [showFailState, setShowFailState] = useState(initialIsValidGatewayUrl) - const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl) - - // Updates the border of the input to indicate validity - useEffect(() => { - setShowFailState(!isValidGatewayUrl) - }, [isValidGatewayUrl]) - - // Updates the border of the input to indicate validity - useEffect(() => { - const isValid = checkValidHttpUrl(value) - setIsValidGatewayUrl(isValid) - setShowFailState(!isValid) - }, [value]) - - const onChange = (event) => setValue(event.target.value) - - const onSubmit = async (event) => { - event.preventDefault() - - try { - await checkViaImgSrc(value) - } catch (e) { - setShowFailState(true) - return - } - - doUpdatePublicGateway(value) - } - - const onReset = async (event) => { - event.preventDefault() - setValue(DEFAULT_PATH_GATEWAY) - doUpdatePublicGateway(DEFAULT_PATH_GATEWAY) - } - - const onKeyPress = (event) => { - if (event.key === 'Enter') { - onSubmit(event) - } - } - - return ( -
    - -
    - - -
    -
    - ) -} +import GatewayForm from '../gateway-form/GatewayForm.js' + +// Empty clears the gateway; Share Links then fall back to a native ipfs:// URI. +const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => ( + +) export default connect( 'doUpdatePublicGateway', diff --git a/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js b/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js index 92620c23c..8917b8024 100644 --- a/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js +++ b/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js @@ -1,93 +1,21 @@ -import React, { useState, useEffect } from 'react' +import React from 'react' import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' -import Button from '../button/button.tsx' -import { checkValidHttpUrl, checkSubdomainGateway, DEFAULT_SUBDOMAIN_GATEWAY } from '../../bundles/gateway.js' - -const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicSubdomainGateway }) => { - const [value, setValue] = useState(publicSubdomainGateway) - const initialIsValidGatewayUrl = !checkValidHttpUrl(value) - const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl) - - // Updates the border of the input to indicate validity - useEffect(() => { - const validateUrl = async () => { - try { - const isValid = await checkSubdomainGateway(value) - setIsValidGatewayUrl(isValid) - } catch (error) { - console.error('Error checking subdomain gateway:', error) - setIsValidGatewayUrl(false) - } - } - - validateUrl() - }, [value]) - - const onChange = (event) => setValue(event.target.value) - - const onSubmit = async (event) => { - event.preventDefault() - - let isValid = false - try { - isValid = await checkSubdomainGateway(value) - setIsValidGatewayUrl(true) - } catch (e) { - setIsValidGatewayUrl(false) - return - } - - isValid && doUpdatePublicSubdomainGateway(value) - } - - const onReset = async (event) => { - event.preventDefault() - setValue(DEFAULT_SUBDOMAIN_GATEWAY) - doUpdatePublicSubdomainGateway(DEFAULT_SUBDOMAIN_GATEWAY) - } - - const onKeyPress = (event) => { - if (event.key === 'Enter') { - onSubmit(event) - } - } - - return ( -
    - -
    - - -
    -
    - ) -} +import GatewayForm from '../gateway-form/GatewayForm.js' + +// Empty clears the gateway; Share Links then fall back to a native ipfs:// URI. +const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicSubdomainGateway }) => ( + +) export default connect( 'doUpdatePublicSubdomainGateway', diff --git a/src/components/share-link-type-form/ShareLinkTypeForm.js b/src/components/share-link-type-form/ShareLinkTypeForm.js new file mode 100644 index 000000000..93d8ce77a --- /dev/null +++ b/src/components/share-link-type-form/ShareLinkTypeForm.js @@ -0,0 +1,115 @@ +import React from 'react' +import { connect } from 'redux-bundler-react' +import { withTranslation, Trans } from 'react-i18next' +import Radio from '../radio/radio.tsx' +import PublicGatewayForm from '../public-gateway-form/PublicGatewayForm.js' +import PublicSubdomainGatewayForm from '../public-subdomain-gateway-form/PublicSubdomainGatewayForm.js' +import { SHARE_LINK_TYPE, gatewayExample } from '../../lib/share-link.js' + +const COMPANION_URL = 'https://docs.ipfs.tech/install/ipfs-companion/' +const GATEWAY_CHECKER_URL = 'https://ipfs.github.io/public-gateway-checker/' +const SELF_HOST_URL = 'https://docs.ipfs.tech/how-to/replace-public-gateways-with-self-hosted-ipfs/' +const ORIGIN_ISOLATION_URL = 'https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy' + +// Native first (recommended), then the gateway options grouped after it. +const NATIVE_OPTION = { value: SHARE_LINK_TYPE.NATIVE, key: 'native', recommended: true, companion: true } +const GATEWAY_OPTIONS = [ + { value: SHARE_LINK_TYPE.LOCAL_PATH, key: 'localPath', subdomain: false }, + { value: SHARE_LINK_TYPE.LOCAL_SUBDOMAIN, key: 'localSubdomain', subdomain: true, isolation: true }, + { value: SHARE_LINK_TYPE.PUBLIC_PATH, key: 'publicPath', subdomain: false, Form: PublicGatewayForm }, + { value: SHARE_LINK_TYPE.PUBLIC_SUBDOMAIN, key: 'publicSubdomain', subdomain: true, isolation: true, Form: PublicSubdomainGatewayForm } +] + +const ShareLinkTypeForm = ({ t, shareLinkType, localGatewayUrl, publicGateway, publicSubdomainGateway, doUpdateShareLinkType }) => { + // An option cannot be chosen until its gateway is configured: the public + // ones via the forms below them, the local ones via the Local Gateway URL + // section or the Kubo config. + const disabledFor = { + [SHARE_LINK_TYPE.LOCAL_PATH]: !localGatewayUrl, + [SHARE_LINK_TYPE.LOCAL_SUBDOMAIN]: !localGatewayUrl, + [SHARE_LINK_TYPE.PUBLIC_PATH]: !publicGateway, + [SHARE_LINK_TYPE.PUBLIC_SUBDOMAIN]: !publicSubdomainGateway + } + + // The gateway each option's example is drawn from; native has none. + const gatewayUrlFor = { + [SHARE_LINK_TYPE.LOCAL_PATH]: localGatewayUrl, + [SHARE_LINK_TYPE.LOCAL_SUBDOMAIN]: localGatewayUrl, + [SHARE_LINK_TYPE.PUBLIC_PATH]: publicGateway, + [SHARE_LINK_TYPE.PUBLIC_SUBDOMAIN]: publicSubdomainGateway + } + + const renderOption = ({ value, key, recommended, companion, isolation, subdomain, Form }) => { + const disabled = Boolean(disabledFor[value]) + const checked = shareLinkType === value + const exampleUrl = gatewayUrlFor[value] + const example = exampleUrl ? gatewayExample(exampleUrl, { subdomain }) : '' + return ( +
    +
    + { if (isChecked && !disabled) doUpdateShareLinkType(value) }} + label={ + + {t(`shareLink.${key}.label`)} + { recommended && + {t('shareLink.recommendedBadge')} } + + } + /> +

    {t(`shareLink.${key}.description`)}

    + { example && +

    + {t('shareLink.usesGateway')} {example} +

    } + { companion && +

    + + IPFS Companion + +

    } + { isolation && +

    + + how browsers keep sites apart + +

    } +
    + { Form && +
    + { disabled && +

    {t('shareLink.publicGatewayEmptyHint')}

    } +
    +
    } +
    + ) + } + + // A plain div, not a form: the nested public gateway forms are real + // elements and forms cannot be nested. Radios apply on change, so no submit. + return ( +
    + {renderOption(NATIVE_OPTION)} +

    {t('shareLink.gatewayGroupTitle')}

    +

    {t('shareLink.gatewayGroupNote')}

    + {GATEWAY_OPTIONS.map(renderOption)} +

    + + Public Gateway Checker + run your own + +

    +
    + ) +} + +export default connect( + 'selectShareLinkType', + 'selectLocalGatewayUrl', + 'selectPublicGateway', + 'selectPublicSubdomainGateway', + 'doUpdateShareLinkType', + withTranslation('settings')(ShareLinkTypeForm) +) diff --git a/src/constants/pinning.ts b/src/constants/pinning.ts index 9d4861959..2cf54b9bb 100644 --- a/src/constants/pinning.ts +++ b/src/constants/pinning.ts @@ -8,7 +8,9 @@ const complianceReportsHomepage = 'https://ipfs-shipyard.github.io/pinning-servi interface PinningServiceTemplate { name: string - icon: string + // Gateway-relative path to the provider icon (no host), resolved against the + // available gateway at render time so it loads the same way as file previews. + iconPath: string apiEndpoint: string visitServiceUrl: string complianceReportUrl?: string @@ -26,25 +28,25 @@ interface SortablePinningServiceTemplate { const pinningServiceTemplates: PinningServiceTemplate[] = [ { name: 'Pinata', - icon: 'https://dweb.link/ipfs/QmVYXV4urQNDzZpddW4zZ9PGvcAbF38BnKWSgch3aNeViW?filename=pinata.svg', + iconPath: 'ipfs/QmVYXV4urQNDzZpddW4zZ9PGvcAbF38BnKWSgch3aNeViW?filename=pinata.svg', apiEndpoint: 'https://api.pinata.cloud/psa', visitServiceUrl: 'https://docs.pinata.cloud/api-reference/pinning-service-api' }, { name: 'Filebase', - icon: 'https://dweb.link/ipfs/QmWBaeu6y1zEcKbsEqCuhuDHPL3W8pZouCPdafMCRCSUWk?filename=filebase.png', + iconPath: 'ipfs/QmWBaeu6y1zEcKbsEqCuhuDHPL3W8pZouCPdafMCRCSUWk?filename=filebase.png', apiEndpoint: 'https://api.filebase.io/v1/ipfs', visitServiceUrl: 'https://docs.filebase.com/api-documentation/ipfs-pinning-service-api' }, { name: 'Functionland', - icon: 'https://dweb.link/ipfs/QmWYEmdYq9Ry2xtb69oZSPXb8Aos24kWdVecsT3txVe38E?filename=functionland.svg', + iconPath: 'ipfs/QmWYEmdYq9Ry2xtb69oZSPXb8Aos24kWdVecsT3txVe38E?filename=functionland.svg', apiEndpoint: 'https://api.cloud.fx.land', visitServiceUrl: 'https://docs.fx.land/pinning-service/ipfs-pinning-service-api' }, { name: '4EVERLAND', - icon: 'https://dweb.link/ipfs/bafkreie4mg2rmoe6fzct4rpwd2d4nuok3yx2mew567nu3s5bfnnmlb65ei?filename=4everland-logo.svg', + iconPath: 'ipfs/bafkreie4mg2rmoe6fzct4rpwd2d4nuok3yx2mew567nu3s5bfnnmlb65ei?filename=4everland-logo.svg', apiEndpoint: 'https://api.4everland.dev', visitServiceUrl: 'https://docs.4everland.org/storage/4ever-pin/pinning-services-api' } diff --git a/src/explore/ExploreContainer.js b/src/explore/ExploreContainer.js index 977715029..59d718933 100644 --- a/src/explore/ExploreContainer.js +++ b/src/explore/ExploreContainer.js @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect } from 'react' import { connect } from 'redux-bundler-react' import { ExplorePage } from 'ipld-explorer-components/pages' import withTour from '../components/tour/withTour.js' @@ -7,15 +7,32 @@ const ExploreContainer = ({ toursEnabled, handleJoyrideCallback, availableGatewayUrl, - publicGateway + publicGateway, + publicSubdomainGateway, + explorerNeedsReload }) => { + // The explorer's Helia node reads the gateway config (kuboGateway) only when + // it boots, so a local gateway change made elsewhere cannot take effect in + // place. Reload lazily, only when the user actually opens Explore. The flag + // lives in non-persisted redux state, so the reload itself clears it. + useEffect(() => { + if (explorerNeedsReload) { + window.location.reload() + } + }, [explorerNeedsReload]) + + // "View on Public Gateway" appears only when the user configured a public + // gateway; prefer the subdomain one. null (not '') hides the link, since + // ExplorePage only falls back to its dweb.link default for undefined. + const publicGatewayUrl = publicSubdomainGateway || publicGateway || null + return (
    ) @@ -25,5 +42,7 @@ export default connect( 'selectToursEnabled', 'selectAvailableGatewayUrl', 'selectPublicGateway', + 'selectPublicSubdomainGateway', + 'selectExplorerNeedsReload', withTour(ExploreContainer) ) diff --git a/src/files/explore-form/files-explore-form.tsx b/src/files/explore-form/files-explore-form.tsx index 767fdb069..5ce91387f 100644 --- a/src/files/explore-form/files-explore-form.tsx +++ b/src/files/explore-form/files-explore-form.tsx @@ -118,7 +118,7 @@ const FilesExploreForm: React.FC = ({ onBrowse: onBrowseP
    - + Paste in a CID or IPFS path
    diff --git a/src/files/file-preview/FilePreview.js b/src/files/file-preview/FilePreview.js index ee79fddcc..371eeb958 100644 --- a/src/files/file-preview/FilePreview.js +++ b/src/files/file-preview/FilePreview.js @@ -4,6 +4,7 @@ import { connect } from 'redux-bundler-react' import { isBinary } from 'istextorbinary' import { Trans, withTranslation } from 'react-i18next' import typeFromExt from '../type-from-ext/index.js' +import { toLoopbackIpUrl, getLocalContentLink } from '../../lib/share-link.js' import ComponentLoader from '../../loader/ComponentLoader.js' import './FilePreview.css' import { CID } from 'multiformats/cid' @@ -53,7 +54,7 @@ const Drag = ({ name, size, cid, path, children }) => { } const Preview = (props) => { - const { t, name, cid, size, availableGatewayUrl, publicGateway, read, onDownload, onClose } = props + const { t, name, cid, size, availableGatewayUrl, localGatewayUrl, publicGateway, shareLinkType, read, onDownload, onClose } = props const [content, setContent] = useState(null) const [hasMoreContent, setHasMoreContent] = useState(false) const [buffer, setBuffer] = useState(null) @@ -86,9 +87,28 @@ const Preview = (props) => { }, // eslint-disable-next-line react-hooks/exhaustive-deps []) - const src = `${availableGatewayUrl}/ipfs/${cid}?filename=${encodeURIComponent(name)}` + const filenameQuery = `?filename=${encodeURIComponent(name)}` + // Embedded subresources (img/video/audio/object) load from the available + // gateway in its IP form; see toLoopbackIpUrl. + const src = toLoopbackIpUrl(`${availableGatewayUrl}/ipfs/${cid}${filenameQuery}`) const className = 'mw-100 mt3 bg-snow-muted pa2 br2 border-box' + // Links that open the file in a new tab. The local one honors the Share Link + // type from Settings, giving content its own origin when the user chose the + // local subdomain gateway; either link is '' when its gateway is not + // configured. + const cidObj = CID.asCID(cid) ?? CID.parse(String(cid)) + const localUrl = getLocalContentLink({ + shareLinkType, + namespace: 'ipfs', + pathId: cidObj.toString(), + subdomainLabel: cidObj.toV1().toString(), + filename: filenameQuery, + localGatewayUrl + }) + const publicUrl = publicGateway ? `${publicGateway}/ipfs/${cid}${filenameQuery}` : '' + const openUrl = localUrl || publicUrl + // Close button header const closeButtonHeader = onClose != null && (
    @@ -108,6 +128,38 @@ const Preview = (props) => {
    ) + const openLinks = localUrl && publicUrl + ? + Try opening it instead with your local gateway or public gateway. + + : localUrl + ? + Try opening it instead with your local gateway. + + : publicUrl + ? + Try opening it instead with your public gateway. + + : null + + const cantPreview = ( +
    +

    {t('cantBePreviewed')} 😢

    + { openLinks &&

    {openLinks}

    } +
    + ) + + // Embedded previews need a gateway to load from; without one, offer the + // open-elsewhere links instead of a broken embed. + if (['audio', 'video', 'pdf', 'image'].includes(type) && !availableGatewayUrl) { + return ( +
    + {closeButtonHeader} + {cantPreview} +
    + ) + } + switch (type) { case 'audio': return ( @@ -116,7 +168,7 @@ const Preview = (props) => { {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
    @@ -126,9 +178,9 @@ const Preview = (props) => {
    {closeButtonHeader} - + {t('noPDFSupport')} - {t('downloadPDF')} + { openUrl && {t('downloadPDF')} } @@ -140,7 +192,7 @@ const Preview = (props) => { {/* eslint-disable-next-line jsx-a11y/media-has-caption */} @@ -150,29 +202,11 @@ const Preview = (props) => {
    {closeButtonHeader} - {name} + {name}
    ) default: { - const srcPublic = `${publicGateway}/ipfs/${cid}?filename=${encodeURIComponent(name)}` - - const cantPreview = ( -
    -

    {t('cantBePreviewed')} 😢

    -

    - { availableGatewayUrl === publicGateway - ? - Try opening it instead with your public gateway. - - : - Try opening it instead with your local gateway or public gateway. - - } -

    -
    - ) - if (content === null) { return (
    @@ -235,22 +269,8 @@ Preview.propTypes = { export default connect( 'selectAvailableGatewayUrl', + 'selectLocalGatewayUrl', 'selectPublicGateway', + 'selectShareLinkType', withTranslation('files')(Preview) ) - -// Potential fix for mixed-content error when redirecting to localhost subdomain -// from https://github.com/ipfs/ipfs-webui/issues/2246#issuecomment-2322192398 -// We do it here and not in src/bundles/config.js because we dont want IPLD -// explorer to open links in path gateway, localhost is desired there. -// -// Context: localhost in Kubo is a subdomain gateway, so http://locahost:8080/ipfs/cid will -// redirect to http://cid.ipfs.localhost:8080 – perhaps subdomains are not -// interpreted as secure context correctly and that triggers forced upgrade to -// https. switching to IP should help. -function safeSubresourceGwUrl (url) { - if (url.startsWith('http://localhost:')) { - return url.replace('http://localhost:', 'http://127.0.0.1:') - } - return url -} diff --git a/src/files/file-preview/file-thumbnail.tsx b/src/files/file-preview/file-thumbnail.tsx index 6c6d0000e..1bd905e03 100644 --- a/src/files/file-preview/file-thumbnail.tsx +++ b/src/files/file-preview/file-thumbnail.tsx @@ -2,6 +2,7 @@ import { CID } from 'multiformats/cid' import React, { useState, useEffect, useCallback, type FC } from 'react' import { connect } from 'redux-bundler-react' import typeFromExt from '../type-from-ext/index.js' +import { toLoopbackIpUrl } from '../../lib/share-link.js' import './file-thumbnail.css' export interface FileThumbnailProps { @@ -39,8 +40,10 @@ const FileThumbnail: FC = ({ name, cid, availableGa return null } - if (type === 'image') { - const src = `${availableGatewayUrl}/ipfs/${cid}?filename=${encodeURIComponent(name)}` + // An image thumbnail loads from the gateway; without one there is nothing to + // point the img at, so fall through to the text preview (or nothing). + if (type === 'image' && availableGatewayUrl) { + const src = toLoopbackIpUrl(`${availableGatewayUrl}/ipfs/${cid}?filename=${encodeURIComponent(name)}`) return (
    diff --git a/src/files/modals/Modals.js b/src/files/modals/Modals.js index 25e013acd..433033f4a 100644 --- a/src/files/modals/Modals.js +++ b/src/files/modals/Modals.js @@ -64,6 +64,9 @@ class Modals extends React.Component { files: [] }, link: '', + shareLinkType: '', + localLink: '', + subdomainLocalLink: '', command: 'ipfs --help' } @@ -132,10 +135,23 @@ class Modals extends React.Component { case SHARE: { this.setState({ link: t('generating'), + shareLinkType: '', + localLink: '', + subdomainLocalLink: '', readyToShow: true }) - onShareLink(files).then(link => this.setState({ link })) + onShareLink(files) + .then(result => this.setState({ + link: result.link, + shareLinkType: result.type, + localLink: result.localLink, + subdomainLocalLink: result.subdomainLocalLink + })) + .catch(err => { + console.error('Failed to generate share link:', err) + this.setState({ link: t('shareModal.linkError') }) + }) break } case RENAME: { @@ -245,7 +261,7 @@ class Modals extends React.Component { render () { const { show, t } = this.props - const { readyToShow, link, rename, command } = this.state + const { readyToShow, link, shareLinkType, localLink, subdomainLocalLink, rename, command } = this.state return (
    @@ -259,6 +275,9 @@ class Modals extends React.Component { diff --git a/src/files/modals/publish-modal/PublishModal.js b/src/files/modals/publish-modal/PublishModal.js index 2421e9199..711e525f6 100644 --- a/src/files/modals/publish-modal/PublishModal.js +++ b/src/files/modals/publish-modal/PublishModal.js @@ -7,12 +7,13 @@ import { Modal, ModalActions, ModalBody } from '../../../components/modal/modal' import Icon from '../../../icons/StrokeSpeaker.js' import { connect } from 'redux-bundler-react' import Radio from '../../../components/radio/radio.js' +import { buildShareLink, toIpnsBase36 } from '../../../lib/share-link.js' import ProgressBar from '../../../components/progress-bar/ProgressBar.js' import GlyphCopy from '../../../icons/GlyphCopy.js' import GlyphTick from '../../../icons/GlyphTick.js' import './PublishModal.css' -export const PublishModal = ({ t, tReady, onLeave, onSubmit, file, ipnsKeys, publicGateway, className, doFetchIpnsKeys, doUpdateExpectedPublishTime, expectedPublishTime, ...props }) => { +export const PublishModal = ({ t, tReady, onLeave, onSubmit, file, ipnsKeys, effectiveShareLinkType, localGatewayUrl, publicGateway, publicSubdomainGateway, className, doFetchIpnsKeys, doUpdateExpectedPublishTime, expectedPublishTime, ...props }) => { const [disabled, setDisabled] = useState(true) const [error, setError] = useState(null) const [selectedKey, setSelectedKey] = useState({ name: '', id: '' }) @@ -47,7 +48,19 @@ export const PublishModal = ({ t, tReady, onLeave, onSubmit, file, ipnsKeys, pub setStart(startTs) await onSubmit(selectedKey.name) - setLink(`${publicGateway}/ipns/${selectedKey.id}`) + // Match the Share Link type chosen in Settings; the effective type + // guarantees buildShareLink returns a link. The name is normalized to a + // base36 libp2p-key so subdomain and native links are valid. + const ipnsName = toIpnsBase36(selectedKey.id) + setLink(buildShareLink({ + type: effectiveShareLinkType, + namespace: 'ipns', + pathId: ipnsName, + subdomainLabel: ipnsName, + localGatewayUrl, + publicGateway, + publicSubdomainGateway + })) // Update the expected time with the new timing. const endTs = new Date().getTime() @@ -159,7 +172,10 @@ PublishModal.defaultProps = { export default connect( 'selectIpnsKeys', 'selectExpectedPublishTime', + 'selectLocalGatewayUrl', 'selectPublicGateway', + 'selectPublicSubdomainGateway', + 'selectEffectiveShareLinkType', 'doFetchIpnsKeys', 'doUpdateExpectedPublishTime', withTranslation('files')(PublishModal) diff --git a/src/files/modals/publish-modal/PublishModal.stories.js b/src/files/modals/publish-modal/PublishModal.stories.js index f0b1b7ac6..7f3670b8a 100644 --- a/src/files/modals/publish-modal/PublishModal.stories.js +++ b/src/files/modals/publish-modal/PublishModal.stories.js @@ -47,7 +47,10 @@ export default { cid: 'QmQK3p7MmycDutWkWAzJ4hNN1YBKK9bLTDz9jTtkWf16wC' }, ipnsKeys, - publicGateway: 'gateway', + effectiveShareLinkType: 'public-path', + localGatewayUrl: 'http://127.0.0.1:8080', + publicGateway: 'https://ipfs.io', + publicSubdomainGateway: 'https://dweb.link', className: 'ma3', doFetchIpnsKeys: () => ipnsKeys, doUpdateExpectedPublishTime: (time) => action(`Update expected publish time: ${time}`), diff --git a/src/files/modals/share-modal/ShareModal.js b/src/files/modals/share-modal/ShareModal.js index 8ea14daf8..e9d9dfdc9 100644 --- a/src/files/modals/share-modal/ShareModal.js +++ b/src/files/modals/share-modal/ShareModal.js @@ -1,48 +1,121 @@ -import React from 'react' +import React, { useState } from 'react' import PropTypes from 'prop-types' import QRCode from 'react-qr-code' import Button from '../../../components/button/button.tsx' -import { withTranslation } from 'react-i18next' +import Checkbox from '../../../components/checkbox/Checkbox.js' +import { withTranslation, Trans } from 'react-i18next' import { CopyToClipboard } from 'react-copy-to-clipboard' import { Modal, ModalActions, ModalBody } from '../../../components/modal/modal' +import { SHARE_LINK_TYPE } from '../../../lib/share-link.js' -const ShareModal = ({ t, tReady, onLeave, link, className, ...props }) => ( - - -

    {t('shareModal.description')}

    -
    - -
    -
    - -
    -
    - - - - - - - -
    -) +const COMPANION_URL = 'https://docs.ipfs.tech/install/ipfs-companion/' + +const ShareModal = ({ t, tReady, onLeave, link, type, localLink, subdomainLocalLink, className, ...props }) => { + const [useLocalLink, setUseLocalLink] = useState(false) + const [useSubdomains, setUseSubdomains] = useState(false) + + // Turning off the local link hides the nested subdomain checkbox, so clear its + // choice too; otherwise re-enabling the local link would jump straight to the + // subdomain variant without the user opting back in. + const toggleLocalLink = (checked) => { + setUseLocalLink(checked) + if (!checked) { + setUseSubdomains(false) + } + } + + const isLocalType = type === SHARE_LINK_TYPE.LOCAL_PATH || type === SHARE_LINK_TYPE.LOCAL_SUBDOMAIN + const isPublicType = type === SHARE_LINK_TYPE.PUBLIC_PATH || type === SHARE_LINK_TYPE.PUBLIC_SUBDOMAIN + + // The Settings choice already decides the link, so the local-link checkboxes + // only add value when that choice is native or public (otherwise redundant). + const offerLocalLink = !isLocalType && Boolean(localLink) + const showingLocalLink = isLocalType || (offerLocalLink && useLocalLink) + + let activeLink = link + if (offerLocalLink && useLocalLink) { + activeLink = useSubdomains && subdomainLocalLink ? subdomainLocalLink : localLink + } + + // A QR code only helps when scanning the link on another device, which is the + // public-gateway case; native and same-machine links are not scanned. + const showQRCode = isPublicType && !(offerLocalLink && useLocalLink) + + let description = t('shareModal.description') + if (showingLocalLink) { + description = t('shareModal.descriptionLocal') + } else if (type === SHARE_LINK_TYPE.NATIVE) { + description = ( + + This native IPFS address opens in apps and browsers that support IPFS, like the IPFS Companion extension. + + ) + } + + return ( + + +

    {description}

    + {showQRCode && ( +
    + +
    + )} +
    + +
    + {offerLocalLink && ( +
    + +
    + )} + {offerLocalLink && useLocalLink && subdomainLocalLink && ( +
    + +
    + )} +
    + + + + + + + +
    + ) +} ShareModal.propTypes = { onLeave: PropTypes.func.isRequired, link: PropTypes.string, + type: PropTypes.string, + localLink: PropTypes.string, + subdomainLocalLink: PropTypes.string, t: PropTypes.func.isRequired, tReady: PropTypes.bool.isRequired } ShareModal.defaultProps = { - className: '' + className: '', + type: '', + localLink: '', + subdomainLocalLink: '' } export default withTranslation('files')(ShareModal) diff --git a/src/files/modals/share-modal/ShareModal.stories.js b/src/files/modals/share-modal/ShareModal.stories.js index 5cde1cc5f..95b293cde 100644 --- a/src/files/modals/share-modal/ShareModal.stories.js +++ b/src/files/modals/share-modal/ShareModal.stories.js @@ -2,6 +2,7 @@ import React from 'react' import { action } from '@storybook/addon-actions' import i18n from '../../../i18n-decorator.js' import ShareModal from './ShareModal.js' +import { SHARE_LINK_TYPE } from '../../../lib/share-link.js' /** * @type {import('@storybook/react').Meta} @@ -18,7 +19,10 @@ export const Share = () => (
    ) diff --git a/src/lib/files.js b/src/lib/files.js index 45a2a618c..141808f0b 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -1,4 +1,5 @@ import filesize from 'filesize' +import { toLoopbackIpUrl } from './share-link.js' /** * @typedef {import('kubo-rpc-client').KuboRPCClient} IPFSService * @typedef {import('../bundles/files/actions').FileStat} FileStat @@ -78,6 +79,7 @@ export async function makeCIDFromFiles (files, ipfs) { * @returns {Promise} */ export async function getDownloadLink (files, gatewayUrl, ipfs) { + gatewayUrl = toLoopbackIpUrl(gatewayUrl) if (files.length === 1) { return getDownloadURL(files[0].type, files[0].name, files[0].cid, gatewayUrl) } @@ -87,43 +89,15 @@ export async function getDownloadLink (files, gatewayUrl, ipfs) { } /** - * Generates a shareable link for the provided files using a subdomain gateway as default or a path gateway as fallback. + * Resolve the root CID for a Share Link: a single item keeps its own CID, while + * a multi-item selection is wrapped in an ephemeral MFS directory. * - * @param {FileStat[]} files - An array of file objects with their respective CIDs and names. - * @param {string} gatewayUrl - The URL of the default IPFS gateway. - * @param {string} subdomainGatewayUrl - The URL of the subdomain gateway. - * @param {IPFSService} ipfs - The IPFS service instance for interacting with the IPFS network. - * @returns {Promise<{link: string, cid: CID}>} - A promise that resolves to an object containing the shareable link and root CID. + * @param {FileStat[]} files + * @param {IPFSService} ipfs + * @returns {Promise} */ -export async function getShareableLink (files, gatewayUrl, subdomainGatewayUrl, ipfs) { - let cid - let filename - - if (files.length === 1) { - cid = files[0].cid - if (files[0].type === 'file') { - filename = `?filename=${encodeURIComponent(files[0].name)}` - } - } else { - cid = await makeCIDFromFiles(files, ipfs) - } - - const url = new URL(subdomainGatewayUrl) - - /** - * dweb.link (subdomain isolation) is listed first as the new default option. - * However, ipfs.io (path gateway fallback) is also listed for CIDs that cannot be represented in a 63-character DNS label. - * This allows users to customize both the subdomain and path gateway they use, with the subdomain gateway being used by default whenever possible. - */ - let shareableLink = '' - const base32Cid = cid.toV1().toString() - if (base32Cid.length < 64) { - shareableLink = `${url.protocol}//${base32Cid}.ipfs.${url.host}${filename || ''}` - } else { - shareableLink = `${gatewayUrl}/ipfs/${cid}${filename || ''}` - } - - return { link: shareableLink, cid } +export async function resolveShareCid (files, ipfs) { + return files.length === 1 ? files[0].cid : makeCIDFromFiles(files, ipfs) } /** @@ -134,6 +108,7 @@ export async function getShareableLink (files, gatewayUrl, subdomainGatewayUrl, * @returns {Promise} */ export async function getCarLink (files, gatewayUrl, ipfs) { + gatewayUrl = toLoopbackIpUrl(gatewayUrl) let cid, filename if (files.length === 1) { diff --git a/src/lib/files.test.js b/src/lib/files.test.js index cab130723..e0d69671b 100644 --- a/src/lib/files.test.js +++ b/src/lib/files.test.js @@ -1,7 +1,6 @@ /* global it, expect */ -import { normalizeFiles, getShareableLink } from './files.js' -import { DEFAULT_SUBDOMAIN_GATEWAY, DEFAULT_PATH_GATEWAY } from '../bundles/gateway.js' import { CID } from 'multiformats/cid' +import { normalizeFiles, getDownloadLink, getCarLink } from './files.js' function expectRightFormat (output) { expect(Array.isArray(output)).toBe(true) @@ -252,35 +251,11 @@ it('drop multiple directories', async () => { expectRightOutput(output, expected) }) -it('should get a subdomain gateway url', async () => { - const ipfs = {} - const myCID = CID.parse('QmZTR5bcpQD7cFgTorqxZDYaew1Wqgfbd2ud9QqGPAkK2V') - const file = { - cid: myCID, - name: 'example.txt' - } - const files = [file] - - const url = new URL(DEFAULT_SUBDOMAIN_GATEWAY) - const { link: shareableLink, cid } = await getShareableLink(files, DEFAULT_PATH_GATEWAY, DEFAULT_SUBDOMAIN_GATEWAY, ipfs) - const base32Cid = 'bafybeifffq3aeaymxejo37sn5fyaf7nn7hkfmzwdxyjculx3lw4tyhk7uy' - const rightShareableLink = `${url.protocol}//${base32Cid}.ipfs.${url.host}` - expect(shareableLink).toBe(rightShareableLink) - expect(cid).toBeDefined() -}) - -it('should get a path gateway url', async () => { - const ipfs = {} - // very long CID v1 (using sha3-512) - const veryLongCidv1 = 'bagaaifcavabu6fzheerrmtxbbwv7jjhc3kaldmm7lbnvfopyrthcvod4m6ygpj3unrcggkzhvcwv5wnhc5ufkgzlsji7agnmofovc2g4a3ui7ja' - const myCID = CID.parse(veryLongCidv1) - const file = { - cid: myCID, - name: 'example.txt' - } - const files = [file] - - const { link: res, cid } = await getShareableLink(files, DEFAULT_PATH_GATEWAY, DEFAULT_SUBDOMAIN_GATEWAY, ipfs) - expect(res).toBe(DEFAULT_PATH_GATEWAY + '/ipfs/' + veryLongCidv1) - expect(cid).toBeDefined() +it('getDownloadLink and getCarLink route a localhost gateway through 127.0.0.1', async () => { + const cid = CID.parse('bafkqac3imvwgy3zao5xxe3de') + const files = [{ type: 'file', name: 'a.txt', cid }] + expect(await getDownloadLink(files, 'http://localhost:8080', null)).toMatch(/^http:\/\/127\.0\.0\.1:8080\/ipfs\//) + expect(await getCarLink(files, 'http://localhost:8080', null)).toMatch(/^http:\/\/127\.0\.0\.1:8080\/ipfs\//) + // a non-localhost gateway is passed through untouched + expect(await getDownloadLink(files, 'https://dweb.link', null)).toMatch(/^https:\/\/dweb\.link\/ipfs\//) }) diff --git a/src/lib/share-link.js b/src/lib/share-link.js new file mode 100644 index 000000000..c27100c21 --- /dev/null +++ b/src/lib/share-link.js @@ -0,0 +1,331 @@ +// Import via 'multiformats/basics' rather than the per-encoding subpaths: the +// subpaths only ship an ESM `exports` condition, which the app's Jest resolver +// mis-resolves to the package root (leaving e.g. base36 undefined). +import { CID, bases, digest as Digest } from 'multiformats/basics' + +const { base36, base58btc } = bases + +/** + * @typedef {import('../bundles/files/actions').FileStat} FileStat + */ + +// libp2p-key multicodec, the codec of an IPNS name expressed as a CID. +const LIBP2P_KEY_CODEC = 0x72 + +/** + * The kinds of link the Share Link and Publish to IPNS flows can produce. The + * value is what we persist in localStorage, so keep these strings stable. + */ +export const SHARE_LINK_TYPE = { + NATIVE: 'native', + LOCAL_PATH: 'local-path', + LOCAL_SUBDOMAIN: 'local-subdomain', + PUBLIC_PATH: 'public-path', + PUBLIC_SUBDOMAIN: 'public-subdomain' +} + +// Default to native ipfs:// URIs: a fresh node shares app-agnostic addresses +// rather than routing through a third-party public gateway until the user opts in. +export const DEFAULT_SHARE_LINK_TYPE = SHARE_LINK_TYPE.NATIVE + +// A subdomain gateway puts the CID (or IPNS name) in a DNS label, which is +// capped at 63 characters. +const DNS_LABEL_MAX = 63 + +// Loopback hosts reach the same local node, so local links use canonical forms: +// the IP for path links and subresources (no DNS, and no localhost subdomain +// redirect that some browsers force-upgrade to https, see +// https://github.com/ipfs/ipfs-webui/issues/2246) and localhost for subdomain +// links (subdomain origins need a hostname). +// https://github.com/ipfs/ipfs-webui/issues/1490 +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]', '[::]']) +const LOOPBACK_IPV4 = '127.0.0.1' +const LOOPBACK_IPV6 = '[::1]' +const LOOPBACK_HOSTNAME = 'localhost' + +/** + * The canonical loopback IP for a loopback hostname, keeping the address + * family of the input ([::] and [::1] stay IPv6). + * + * @param {string} hostname - a member of LOOPBACK_HOSTNAMES + * @returns {string} + */ +function loopbackIp (hostname) { + return hostname.startsWith('[') ? LOOPBACK_IPV6 : LOOPBACK_IPV4 +} + +/** + * Whether a loopback host in this URL may be rewritten to another loopback + * form. Only http qualifies: an https certificate covers the exact hostname, + * so swapping localhost and 127.0.0.1 would break TLS validation. + * + * @param {URL} url + * @returns {boolean} + */ +function isRewritableLoopback (url) { + return url.protocol === 'http:' && LOOPBACK_HOSTNAMES.has(url.hostname) +} + +/** + * Rewrite a loopback host in an http URL to its canonical IP form (127.0.0.1 + * or [::1]). This is the form for subresources (img/video/object embeds) and + * path links: it avoids Kubo's localhost subdomain redirect, which some + * browsers treat as an insecure context and force-upgrade to https, breaking + * the load (https://github.com/ipfs/ipfs-webui/issues/2246). Accepts a full + * content URL or a bare gateway root; https, non-loopback, and unparseable + * values pass through unchanged. + * + * @param {string} url + * @returns {string} + */ +export function toLoopbackIpUrl (url) { + let parsed + try { + parsed = new URL(url) + } catch { + return url + } + if (!isRewritableLoopback(parsed)) return url + // Surgical host swap: URL serialization would append a trailing slash to a + // bare gateway root, breaking callers that concatenate paths onto it. + return url.replace(parsed.hostname, loopbackIp(parsed.hostname)) +} + +/** + * Build the `?filename=...` query that hints a gateway at a download name. + * Only a single file gets one; directories and multi-file selections do not. + * + * @param {FileStat[]} files + * @returns {string} the query string, or '' when no filename hint applies + */ +export function getFilenameQuery (files) { + if (files.length === 1 && files[0].type === 'file') { + return `?filename=${encodeURIComponent(files[0].name)}` + } + return '' +} + +/** + * Whether a URL hostname is a bare IP literal rather than a domain name. + * Subdomain gateways serve one origin per CID under a parent domain, so they + * cannot be built on an IP such as 192.168.1.5. + * + * @param {string} hostname - a URL hostname (IPv6 keeps its surrounding brackets) + * @returns {boolean} + */ +function isIpHostname (hostname) { + // IPv6 literals are bracketed in a URL hostname, e.g. [::1] + if (hostname.startsWith('[')) { + return true + } + // IPv4 dotted quad, e.g. 127.0.0.1 + return /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) +} + +/** + * Build a path gateway link: `//`. + * + * @param {string} gatewayUrl + * @param {string} namespace - 'ipfs' or 'ipns' + * @param {string} id - CID string or IPNS name + * @param {string} filename - `?filename=...` query, or '' + * @param {{loopback?: boolean}} [opts] - normalize an http loopback host to its IP form + * @returns {string} the link, or '' when gatewayUrl is empty + */ +function pathLink (gatewayUrl, namespace, id, filename, opts = {}) { + if (!gatewayUrl) return '' + const url = new URL(gatewayUrl) + let host = url.host + if (opts.loopback && isRewritableLoopback(url)) { + const port = url.port ? `:${url.port}` : '' + host = `${loopbackIp(url.hostname)}${port}` + } + // Preserve any subpath the gateway is mounted under (e.g. a reverse proxy at + // https://example.com/gw); '/' collapses to an empty base. + const base = url.pathname.replace(/\/+$/, '') + return `${url.protocol}//${host}${base}/${namespace}/${id}${filename}` +} + +/** + * Build a subdomain gateway link: `