From 82060156e3d469a74e930368287ddf97a1d17f39 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 10 Mar 2026 18:01:50 +0100 Subject: [PATCH 1/5] feat: add local gateway URL options to share modal allow copying local gateway links for use in external apps, with optional localhost subdomain mode for web apps - actions.js: doFilesShareLink returns local and subdomain links - ShareModal: checkbox to toggle local link, nested checkbox for localhost subdomains, QR hidden in local mode - Modals: passes new link variants to ShareModal - en/files.json: added translation keys for new UI elements --- public/locales/en/files.json | 5 +- src/bundles/files/actions.js | 16 +++- src/files/modals/Modals.js | 14 ++- src/files/modals/share-modal/ShareModal.js | 93 +++++++++++++------ .../modals/share-modal/ShareModal.stories.js | 2 + 5 files changed, 98 insertions(+), 32 deletions(-) diff --git a/public/locales/en/files.json b/public/locales/en/files.json index dd96da470..d1a3094e5 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -28,7 +28,10 @@ }, "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.", + "useLocalLink": "Local link for other apps on this machine", + "useSubdomains": "Use localhost subdomains for web apps" }, "renameModal": { "titleFile": "Rename file", diff --git a/src/bundles/files/actions.js b/src/bundles/files/actions.js index 44bca1289..401c18612 100644 --- a/src/bundles/files/actions.js +++ b/src/bundles/files/actions.js @@ -600,12 +600,26 @@ const actions = () => ({ // ensureMFS deliberately omitted here, see https://github.com/ipfs/ipfs-webui/issues/1744 for context. const publicGateway = store.selectPublicGateway() const publicSubdomainGateway = store.selectPublicSubdomainGateway() + const gatewayUrl = store.selectGatewayUrl() const { link: shareableLink, cid } = await getShareableLink(files, publicGateway, publicSubdomainGateway, ipfs) + // Build local gateway link for use in external apps + let filename = '' + if (files.length === 1 && files[0].type === 'file') { + filename = `?filename=${encodeURIComponent(files[0].name)}` + } + const localLink = `${gatewayUrl}/ipfs/${cid}${filename}` + + // Build localhost subdomain link for web apps (origin isolation) + const gwUrl = new URL(gatewayUrl) + const base32Cid = cid.toV1().toString() + const port = gwUrl.port ? `:${gwUrl.port}` : '' + const subdomainLocalLink = `http://${base32Cid}.ipfs.localhost${port}/${filename}` + // Trigger background provide operation with the CID from getShareableLink dispatchAsyncProvide(cid, ipfs) - return shareableLink + return { link: shareableLink, localLink, subdomainLocalLink } }), /** diff --git a/src/files/modals/Modals.js b/src/files/modals/Modals.js index 25e013acd..e4284ac5e 100644 --- a/src/files/modals/Modals.js +++ b/src/files/modals/Modals.js @@ -64,6 +64,8 @@ class Modals extends React.Component { files: [] }, link: '', + localLink: '', + subdomainLocalLink: '', command: 'ipfs --help' } @@ -132,10 +134,16 @@ class Modals extends React.Component { case SHARE: { this.setState({ link: t('generating'), + localLink: '', + subdomainLocalLink: '', readyToShow: true }) - onShareLink(files).then(link => this.setState({ link })) + onShareLink(files).then(result => this.setState({ + link: result.link, + localLink: result.localLink, + subdomainLocalLink: result.subdomainLocalLink + })) break } case RENAME: { @@ -245,7 +253,7 @@ class Modals extends React.Component { render () { const { show, t } = this.props - const { readyToShow, link, rename, command } = this.state + const { readyToShow, link, localLink, subdomainLocalLink, rename, command } = this.state return (
@@ -259,6 +267,8 @@ class Modals extends React.Component { diff --git a/src/files/modals/share-modal/ShareModal.js b/src/files/modals/share-modal/ShareModal.js index 8ea14daf8..794c4f6fb 100644 --- a/src/files/modals/share-modal/ShareModal.js +++ b/src/files/modals/share-modal/ShareModal.js @@ -1,48 +1,85 @@ -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 Checkbox from '../../../components/checkbox/Checkbox.js' import { withTranslation } from 'react-i18next' import { CopyToClipboard } from 'react-copy-to-clipboard' import { Modal, ModalActions, ModalBody } from '../../../components/modal/modal' -const ShareModal = ({ t, tReady, onLeave, link, className, ...props }) => ( - - -

{t('shareModal.description')}

-
- -
-
- -
-
+const ShareModal = ({ t, tReady, onLeave, link, localLink, subdomainLocalLink, className, ...props }) => { + const [useLocalLink, setUseLocalLink] = useState(false) + const [useSubdomains, setUseSubdomains] = useState(false) - - - - - - -
-) + let activeLink = link + if (useLocalLink && localLink) { + activeLink = useSubdomains && subdomainLocalLink ? subdomainLocalLink : localLink + } + + return ( + + +

+ {useLocalLink ? t('shareModal.descriptionLocal') : t('shareModal.description')} +

+ {!useLocalLink && ( +
+ +
+ )} +
+ +
+ {localLink && ( +
+ +
+ )} + {useLocalLink && subdomainLocalLink && ( +
+ +
+ )} +
+ + + + + + + +
+ ) +} ShareModal.propTypes = { onLeave: PropTypes.func.isRequired, link: PropTypes.string, + localLink: PropTypes.string, + subdomainLocalLink: PropTypes.string, t: PropTypes.func.isRequired, tReady: PropTypes.bool.isRequired } ShareModal.defaultProps = { - className: '' + className: '', + 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..299310f8c 100644 --- a/src/files/modals/share-modal/ShareModal.stories.js +++ b/src/files/modals/share-modal/ShareModal.stories.js @@ -19,6 +19,8 @@ export const Share = () => (
) From fac4ef8db54fc44db5cef51e466a6e529e0ae3e9 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 30 Jun 2026 04:17:51 +0200 Subject: [PATCH 2/5] feat: honor gateway override in local links The share modal's local links flow through selectGatewayUrl, so the Local Gateway URL override added in #2486 reaches them. Subdomain links are offered only for domain gateways, since subdomain gateways cannot resolve on a bare IP such as 127.0.0.1. - files.js: add getLocalLinks, deriving both links from the gateway url (host, port, scheme); gate the subdomain link to domain hosts and to CIDs that fit a 63-char DNS label; share filename and base32 logic with getShareableLink - actions.js: build the local links via getLocalLinks - share-modal: clear the subdomain choice when the local link is unchecked, so it cannot silently reapply - protocol.ts: type the share-link result as ShareLinks, not string --- src/bundles/files/actions.js | 17 +--- src/bundles/files/protocol.ts | 8 +- src/files/modals/share-modal/ShareModal.js | 12 ++- .../modals/share-modal/ShareModal.stories.js | 2 +- src/lib/files.js | 82 +++++++++++++++---- src/lib/files.test.js | 49 ++++++++++- 6 files changed, 135 insertions(+), 35 deletions(-) diff --git a/src/bundles/files/actions.js b/src/bundles/files/actions.js index 401c18612..03421f24b 100644 --- a/src/bundles/files/actions.js +++ b/src/bundles/files/actions.js @@ -1,7 +1,7 @@ /* eslint-disable require-yield */ import { join, dirname, basename } from 'path' -import { getDownloadLink, getShareableLink, getCarLink } from '../../lib/files.js' +import { getDownloadLink, getShareableLink, getCarLink, getLocalLinks } from '../../lib/files.js' import countDirs from '../../lib/count-dirs.js' import memoize from 'p-memoize' import all from 'it-all' @@ -603,18 +603,9 @@ const actions = () => ({ const gatewayUrl = store.selectGatewayUrl() const { link: shareableLink, cid } = await getShareableLink(files, publicGateway, publicSubdomainGateway, ipfs) - // Build local gateway link for use in external apps - let filename = '' - if (files.length === 1 && files[0].type === 'file') { - filename = `?filename=${encodeURIComponent(files[0].name)}` - } - const localLink = `${gatewayUrl}/ipfs/${cid}${filename}` - - // Build localhost subdomain link for web apps (origin isolation) - const gwUrl = new URL(gatewayUrl) - const base32Cid = cid.toV1().toString() - const port = gwUrl.port ? `:${gwUrl.port}` : '' - const subdomainLocalLink = `http://${base32Cid}.ipfs.localhost${port}/${filename}` + // Local gateway links for opening content in other apps on this machine. + // selectGatewayUrl honors the user's Local Gateway URL override. + const { localLink, subdomainLocalLink } = getLocalLinks(files, cid, gatewayUrl) // Trigger background provide operation with the CID from getShareableLink dispatchAsyncProvide(cid, ipfs) diff --git a/src/bundles/files/protocol.ts b/src/bundles/files/protocol.ts index 90c9f450b..9d0165057 100644 --- a/src/bundles/files/protocol.ts +++ b/src/bundles/files/protocol.ts @@ -78,6 +78,12 @@ export type FileDownload = { filename: string } export type DownloadLink = Perform<'FILES_DOWNLOADLINK', Error, FileDownload, void> +export type ShareLinks = { + link: string + localLink: string + subdomainLocalLink: string +} +export type ShareLink = Perform<'FILES_SHARE_LINK', Error, ShareLinks, void> export type Message = | { type: 'FILES_CLEAR_ALL' } @@ -91,7 +97,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/files/modals/share-modal/ShareModal.js b/src/files/modals/share-modal/ShareModal.js index 794c4f6fb..03f2657ff 100644 --- a/src/files/modals/share-modal/ShareModal.js +++ b/src/files/modals/share-modal/ShareModal.js @@ -11,6 +11,16 @@ const ShareModal = ({ t, tReady, onLeave, link, localLink, subdomainLocalLink, c 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) + } + } + let activeLink = link if (useLocalLink && localLink) { activeLink = useSubdomains && subdomainLocalLink ? subdomainLocalLink : localLink @@ -41,7 +51,7 @@ const ShareModal = ({ t, tReady, onLeave, link, localLink, subdomainLocalLink, c
diff --git a/src/files/modals/share-modal/ShareModal.stories.js b/src/files/modals/share-modal/ShareModal.stories.js index 299310f8c..214fe5001 100644 --- a/src/files/modals/share-modal/ShareModal.stories.js +++ b/src/files/modals/share-modal/ShareModal.stories.js @@ -19,7 +19,7 @@ export const Share = () => ( diff --git a/src/lib/files.js b/src/lib/files.js index 45a2a618c..57bc609a0 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -86,6 +86,37 @@ export async function getDownloadLink (files, gatewayUrl, ipfs) { return getDownloadURL('directory', '', cid, gatewayUrl) } +/** + * 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 + */ +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 127.0.0.1 or [::1]. + * + * @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) +} + /** * Generates a shareable link for the provided files using a subdomain gateway as default or a path gateway as fallback. * @@ -96,18 +127,8 @@ export async function getDownloadLink (files, gatewayUrl, ipfs) { * @returns {Promise<{link: string, cid: CID}>} - A promise that resolves to an object containing the shareable link and root CID. */ 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 cid = files.length === 1 ? files[0].cid : await makeCIDFromFiles(files, ipfs) + const filename = getFilenameQuery(files) const url = new URL(subdomainGatewayUrl) /** @@ -115,17 +136,42 @@ export async function getShareableLink (files, gatewayUrl, subdomainGatewayUrl, * 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 || ''}` - } + const shareableLink = base32Cid.length < 64 + ? `${url.protocol}//${base32Cid}.ipfs.${url.host}${filename}` + : `${gatewayUrl}/ipfs/${cid}${filename}` return { link: shareableLink, cid } } +/** + * Build local gateway links for opening content in other apps on the same + * machine. The path link works on any gateway. The subdomain link gives web + * apps an isolated origin and is set only when the gateway is reachable by a + * domain name (subdomain gateways do not work on bare IPs) and the CIDv1 fits + * in a 63-character DNS label. When it does not apply, subdomainLocalLink is ''. + * + * gatewayUrl honors the user's Local Gateway URL override, so both links point + * at whatever host, port and scheme the override (or Kubo config) specifies. + * + * @param {FileStat[]} files + * @param {CID} cid - root CID, already resolved by getShareableLink + * @param {string} gatewayUrl - the local gateway URL + * @returns {{localLink: string, subdomainLocalLink: string}} + */ +export function getLocalLinks (files, cid, gatewayUrl) { + const filename = getFilenameQuery(files) + const localLink = `${gatewayUrl}/ipfs/${cid}${filename}` + + const url = new URL(gatewayUrl) + const base32Cid = cid.toV1().toString() + const subdomainLocalLink = !isIpHostname(url.hostname) && base32Cid.length < 64 + ? `${url.protocol}//${base32Cid}.ipfs.${url.host}${filename}` + : '' + + return { localLink, subdomainLocalLink } +} + /** * * @param {FileStat[]} files diff --git a/src/lib/files.test.js b/src/lib/files.test.js index cab130723..ac29b9703 100644 --- a/src/lib/files.test.js +++ b/src/lib/files.test.js @@ -1,5 +1,5 @@ /* global it, expect */ -import { normalizeFiles, getShareableLink } from './files.js' +import { normalizeFiles, getShareableLink, getLocalLinks } from './files.js' import { DEFAULT_SUBDOMAIN_GATEWAY, DEFAULT_PATH_GATEWAY } from '../bundles/gateway.js' import { CID } from 'multiformats/cid' @@ -284,3 +284,50 @@ it('should get a path gateway url', async () => { expect(res).toBe(DEFAULT_PATH_GATEWAY + '/ipfs/' + veryLongCidv1) expect(cid).toBeDefined() }) + +const SHORT_CID = CID.parse('QmZTR5bcpQD7cFgTorqxZDYaew1Wqgfbd2ud9QqGPAkK2V') +const SHORT_CID_BASE32 = 'bafybeifffq3aeaymxejo37sn5fyaf7nn7hkfmzwdxyjculx3lw4tyhk7uy' + +it('getLocalLinks builds path and subdomain links for a domain gateway', () => { + const files = [{ cid: SHORT_CID, name: 'example.txt', type: 'file' }] + const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'http://localhost:8080') + + expect(localLink).toBe(`http://localhost:8080/ipfs/${SHORT_CID}?filename=example.txt`) + expect(subdomainLocalLink).toBe(`http://${SHORT_CID_BASE32}.ipfs.localhost:8080?filename=example.txt`) +}) + +it('getLocalLinks omits the subdomain link for an IP gateway', () => { + const files = [{ cid: SHORT_CID, name: 'example.txt', type: 'file' }] + + const ipv4 = getLocalLinks(files, SHORT_CID, 'http://127.0.0.1:8080') + expect(ipv4.localLink).toBe(`http://127.0.0.1:8080/ipfs/${SHORT_CID}?filename=example.txt`) + expect(ipv4.subdomainLocalLink).toBe('') + + const ipv6 = getLocalLinks(files, SHORT_CID, 'http://[::1]:8080') + expect(ipv6.subdomainLocalLink).toBe('') +}) + +it('getLocalLinks honors the user override host and scheme', () => { + const files = [{ cid: SHORT_CID, name: 'example.txt', type: 'file' }] + const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'https://gw.example.com') + + expect(localLink).toBe(`https://gw.example.com/ipfs/${SHORT_CID}?filename=example.txt`) + expect(subdomainLocalLink).toBe(`https://${SHORT_CID_BASE32}.ipfs.gw.example.com?filename=example.txt`) +}) + +it('getLocalLinks omits the subdomain link when the CID exceeds a DNS label', () => { + const longCid = CID.parse('bagaaifcavabu6fzheerrmtxbbwv7jjhc3kaldmm7lbnvfopyrthcvod4m6ygpj3unrcggkzhvcwv5wnhc5ufkgzlsji7agnmofovc2g4a3ui7ja') + const files = [{ cid: longCid, name: 'example.txt', type: 'file' }] + const { localLink, subdomainLocalLink } = getLocalLinks(files, longCid, 'http://localhost:8080') + + expect(localLink).toBe(`http://localhost:8080/ipfs/${longCid}?filename=example.txt`) + expect(subdomainLocalLink).toBe('') +}) + +it('getLocalLinks adds no filename query for a directory', () => { + const files = [{ cid: SHORT_CID, name: 'a-directory', type: 'directory' }] + const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'http://localhost:8080') + + expect(localLink).toBe(`http://localhost:8080/ipfs/${SHORT_CID}`) + expect(subdomainLocalLink).toBe(`http://${SHORT_CID_BASE32}.ipfs.localhost:8080`) +}) From ccfab4dca10bf35079193c6d01e0c9b4a386c445 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 30 Jun 2026 22:36:01 +0200 Subject: [PATCH 3/5] fix: loopback share links and error handling Refine the local gateway share links so they resolve from other apps and never leave the modal hanging when generation fails. - files.js: a single loopback check in getLocalLinks drives both links, so the path link uses 127.0.0.1 (no DNS) and the subdomain link uses localhost (origins need a hostname); domain gateways and non-loopback IPs are left as-is - modals: catch a failed link generation and show an error instead of a stuck "Generating..." - en/files.json: add the error string; mark the plain link as HTTP --- public/locales/en/files.json | 5 +++-- src/files/modals/Modals.js | 15 ++++++++----- src/lib/files.js | 42 +++++++++++++++++++++++++----------- src/lib/files.test.js | 39 +++++++++++++++++---------------- 4 files changed, 63 insertions(+), 38 deletions(-) diff --git a/public/locales/en/files.json b/public/locales/en/files.json index d1a3094e5..983317f48 100644 --- a/public/locales/en/files.json +++ b/public/locales/en/files.json @@ -30,8 +30,9 @@ "title": "Share files", "description": "Copy the link below and share it with your friends.", "descriptionLocal": "Use this link to open in apps running on this machine.", - "useLocalLink": "Local link for other apps on this machine", - "useSubdomains": "Use localhost subdomains for web apps" + "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/src/files/modals/Modals.js b/src/files/modals/Modals.js index e4284ac5e..1ac870bd2 100644 --- a/src/files/modals/Modals.js +++ b/src/files/modals/Modals.js @@ -139,11 +139,16 @@ class Modals extends React.Component { readyToShow: true }) - onShareLink(files).then(result => this.setState({ - link: result.link, - localLink: result.localLink, - subdomainLocalLink: result.subdomainLocalLink - })) + onShareLink(files) + .then(result => this.setState({ + link: result.link, + 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: { diff --git a/src/lib/files.js b/src/lib/files.js index 57bc609a0..84ab6d547 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -100,10 +100,18 @@ function getFilenameQuery (files) { return '' } +// Loopback hosts, matched against a URL hostname (IPv6 keeps its brackets). They +// all reach the same local node, so its links use canonical forms: the IP for +// the path link (no DNS needed) and localhost for the subdomain link (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_IP = '127.0.0.1' +const LOOPBACK_HOSTNAME = 'localhost' + /** * 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 127.0.0.1 or [::1]. + * 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} @@ -146,13 +154,13 @@ export async function getShareableLink (files, gatewayUrl, subdomainGatewayUrl, /** * Build local gateway links for opening content in other apps on the same - * machine. The path link works on any gateway. The subdomain link gives web - * apps an isolated origin and is set only when the gateway is reachable by a - * domain name (subdomain gateways do not work on bare IPs) and the CIDv1 fits - * in a 63-character DNS label. When it does not apply, subdomainLocalLink is ''. + * machine, honoring the user's Local Gateway URL override. * - * gatewayUrl honors the user's Local Gateway URL override, so both links point - * at whatever host, port and scheme the override (or Kubo config) specifies. + * For a loopback gateway (localhost, 127.0.0.1, ...) the two links use canonical + * forms: the path link uses 127.0.0.1 (no DNS needed) and the subdomain link + * uses localhost (subdomain origins need a hostname). A real domain gateway + * keeps its host for both. A non-loopback IP gets no subdomain link, and neither + * does a CIDv1 too long for a 63-character DNS label; subdomainLocalLink is ''. * * @param {FileStat[]} files * @param {CID} cid - root CID, already resolved by getShareableLink @@ -161,13 +169,23 @@ export async function getShareableLink (files, gatewayUrl, subdomainGatewayUrl, */ export function getLocalLinks (files, cid, gatewayUrl) { const filename = getFilenameQuery(files) - const localLink = `${gatewayUrl}/ipfs/${cid}${filename}` - const url = new URL(gatewayUrl) + const isLoopback = LOOPBACK_HOSTNAMES.has(url.hostname) + const port = url.port ? `:${url.port}` : '' + + const localLink = isLoopback + ? `${url.protocol}//${LOOPBACK_IP}${port}/ipfs/${cid}${filename}` + : `${gatewayUrl}/ipfs/${cid}${filename}` + const base32Cid = cid.toV1().toString() - const subdomainLocalLink = !isIpHostname(url.hostname) && base32Cid.length < 64 - ? `${url.protocol}//${base32Cid}.ipfs.${url.host}${filename}` - : '' + let subdomainLocalLink = '' + if (base32Cid.length < 64) { + if (isLoopback) { + subdomainLocalLink = `${url.protocol}//${base32Cid}.ipfs.${LOOPBACK_HOSTNAME}${port}${filename}` + } else if (!isIpHostname(url.hostname)) { + subdomainLocalLink = `${url.protocol}//${base32Cid}.ipfs.${url.host}${filename}` + } + } return { localLink, subdomainLocalLink } } diff --git a/src/lib/files.test.js b/src/lib/files.test.js index ac29b9703..5410b2911 100644 --- a/src/lib/files.test.js +++ b/src/lib/files.test.js @@ -288,31 +288,32 @@ it('should get a path gateway url', async () => { const SHORT_CID = CID.parse('QmZTR5bcpQD7cFgTorqxZDYaew1Wqgfbd2ud9QqGPAkK2V') const SHORT_CID_BASE32 = 'bafybeifffq3aeaymxejo37sn5fyaf7nn7hkfmzwdxyjculx3lw4tyhk7uy' -it('getLocalLinks builds path and subdomain links for a domain gateway', () => { - const files = [{ cid: SHORT_CID, name: 'example.txt', type: 'file' }] - const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'http://localhost:8080') - - expect(localLink).toBe(`http://localhost:8080/ipfs/${SHORT_CID}?filename=example.txt`) - expect(subdomainLocalLink).toBe(`http://${SHORT_CID_BASE32}.ipfs.localhost:8080?filename=example.txt`) +it('getLocalLinks gives loopback gateways an IP path link and a localhost subdomain link', () => { + // localhost, 127.0.0.1 and [::1] are the same local node: the path link uses + // the IP (no DNS) and the subdomain link uses localhost (origins need a name). + for (const gateway of ['http://localhost:8080', 'http://127.0.0.1:8080', 'http://[::1]:8080']) { + const files = [{ cid: SHORT_CID, name: 'example.txt', type: 'file' }] + const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, gateway) + + expect(localLink).toBe(`http://127.0.0.1:8080/ipfs/${SHORT_CID}?filename=example.txt`) + expect(subdomainLocalLink).toBe(`http://${SHORT_CID_BASE32}.ipfs.localhost:8080?filename=example.txt`) + } }) -it('getLocalLinks omits the subdomain link for an IP gateway', () => { +it('getLocalLinks leaves a non-loopback domain gateway unchanged', () => { const files = [{ cid: SHORT_CID, name: 'example.txt', type: 'file' }] + const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'https://gw.example.com') - const ipv4 = getLocalLinks(files, SHORT_CID, 'http://127.0.0.1:8080') - expect(ipv4.localLink).toBe(`http://127.0.0.1:8080/ipfs/${SHORT_CID}?filename=example.txt`) - expect(ipv4.subdomainLocalLink).toBe('') - - const ipv6 = getLocalLinks(files, SHORT_CID, 'http://[::1]:8080') - expect(ipv6.subdomainLocalLink).toBe('') + expect(localLink).toBe(`https://gw.example.com/ipfs/${SHORT_CID}?filename=example.txt`) + expect(subdomainLocalLink).toBe(`https://${SHORT_CID_BASE32}.ipfs.gw.example.com?filename=example.txt`) }) -it('getLocalLinks honors the user override host and scheme', () => { +it('getLocalLinks omits the subdomain link for a non-loopback IP gateway', () => { const files = [{ cid: SHORT_CID, name: 'example.txt', type: 'file' }] - const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'https://gw.example.com') + const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'http://192.168.1.5:8080') - expect(localLink).toBe(`https://gw.example.com/ipfs/${SHORT_CID}?filename=example.txt`) - expect(subdomainLocalLink).toBe(`https://${SHORT_CID_BASE32}.ipfs.gw.example.com?filename=example.txt`) + expect(localLink).toBe(`http://192.168.1.5:8080/ipfs/${SHORT_CID}?filename=example.txt`) + expect(subdomainLocalLink).toBe('') }) it('getLocalLinks omits the subdomain link when the CID exceeds a DNS label', () => { @@ -320,7 +321,7 @@ it('getLocalLinks omits the subdomain link when the CID exceeds a DNS label', () const files = [{ cid: longCid, name: 'example.txt', type: 'file' }] const { localLink, subdomainLocalLink } = getLocalLinks(files, longCid, 'http://localhost:8080') - expect(localLink).toBe(`http://localhost:8080/ipfs/${longCid}?filename=example.txt`) + expect(localLink).toBe(`http://127.0.0.1:8080/ipfs/${longCid}?filename=example.txt`) expect(subdomainLocalLink).toBe('') }) @@ -328,6 +329,6 @@ it('getLocalLinks adds no filename query for a directory', () => { const files = [{ cid: SHORT_CID, name: 'a-directory', type: 'directory' }] const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'http://localhost:8080') - expect(localLink).toBe(`http://localhost:8080/ipfs/${SHORT_CID}`) + expect(localLink).toBe(`http://127.0.0.1:8080/ipfs/${SHORT_CID}`) expect(subdomainLocalLink).toBe(`http://${SHORT_CID_BASE32}.ipfs.localhost:8080`) }) From a1474a808f6270c7dd6de5785cfcce1bb44b9647 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Thu, 2 Jul 2026 00:40:32 +0200 Subject: [PATCH 4/5] feat: native share links by default Share Link and Publish to IPNS copy native ipfs:// and ipns:// URIs by default, with opt-in HTTP gateway links for cases that need them. - add a Settings "Sharing IPFS Links" section to choose the link type: native, local path/subdomain, or public path/subdomain - public gateways default to empty and are opt-in; a public option stays disabled until its URL is set, and clearing it reverts to native - native URIs use canonical CIDv1: base32 for /ipfs, base36 libp2p-key for /ipns; the same choice drives Publish to IPNS - local links normalize loopback per type: 127.0.0.1 for path, localhost for subdomain - remove online gateway reachability probes; previews, thumbnails, IPLD explorer and IPNS links load from the local gateway (override or Kubo config), never a third-party gateway unless configured - consolidate the localhost to 127.0.0.1 subresource fix across previews, thumbnails, downloads, CAR links and pinning icons - Explore links honor the configured gateways; a local gateway change refreshes previews at once and reloads the explorer on the next visit --- public/locales/en/app.json | 9 +- public/locales/en/files.json | 2 + public/locales/en/settings.json | 35 ++- public/locales/en/welcome.json | 2 +- src/bundles/config.js | 67 +---- src/bundles/files/actions.js | 37 ++- src/bundles/files/protocol.ts | 1 + src/bundles/gateway.js | 242 +++++++---------- src/bundles/gateway.test.js | 89 ++++++- src/bundles/pinning.js | 13 +- src/components/about-ipfs/AboutIpfs.js | 2 +- .../api-address-form/api-address-form.tsx | 2 +- .../local-gateway-form/LocalGatewayForm.js | 20 +- .../public-gateway-form/PublicGatewayForm.js | 48 ++-- .../PublicSubdomainGatewayForm.js | 54 ++-- .../share-link-type-form/ShareLinkTypeForm.js | 111 ++++++++ src/constants/pinning.ts | 12 +- src/explore/ExploreContainer.js | 25 +- src/files/explore-form/files-explore-form.tsx | 2 +- src/files/file-preview/FilePreview.js | 31 +-- src/files/file-preview/file-thumbnail.tsx | 3 +- src/files/modals/Modals.js | 6 +- .../modals/publish-modal/PublishModal.js | 22 +- .../publish-modal/PublishModal.stories.js | 5 +- src/files/modals/share-modal/ShareModal.js | 44 +++- .../modals/share-modal/ShareModal.stories.js | 6 +- src/lib/files.js | 127 +++------ src/lib/files.test.js | 92 ++----- src/lib/share-link.js | 245 ++++++++++++++++++ src/lib/share-link.test.js | 213 +++++++++++++++ src/settings/SettingsPage.js | 30 +-- test/e2e/ipns.test.js | 4 + test/e2e/settings.test.js | 56 ++-- test/e2e/share-link.test.js | 116 +++++++++ tsconfig.json | 1 + 35 files changed, 1200 insertions(+), 574 deletions(-) create mode 100644 src/components/share-link-type-form/ShareLinkTypeForm.js create mode 100644 src/lib/share-link.js create mode 100644 src/lib/share-link.test.js create mode 100644 test/e2e/share-link.test.js 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 983317f48..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", @@ -30,6 +31,7 @@ "title": "Share files", "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." 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..9674eb58f 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 @@ -127,31 +103,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 03421f24b..f61b129e4 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, getLocalLinks } from '../../lib/files.js' +import { getDownloadLink, getCarLink, resolveShareCid } from '../../lib/files.js' +import { SHARE_LINK_TYPE, 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' @@ -155,6 +156,7 @@ const getPinCIDs = (ipfs) => map(getRawPins(ipfs), (pin) => pin.cid) * @property {function():string} selectGatewayUrl * @property {function():string} selectPublicGateway * @property {function():string} selectPublicSubdomainGateway + * @property {function():string} selectEffectiveShareLinkType * * @typedef {Object} UnkonwActions * @property {function(string):Promise} doUpdateHash @@ -598,19 +600,38 @@ 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) + const gatewayUrl = store.selectGatewayUrl() const publicGateway = store.selectPublicGateway() const publicSubdomainGateway = store.selectPublicSubdomainGateway() - const gatewayUrl = store.selectGatewayUrl() - const { link: shareableLink, cid } = await getShareableLink(files, publicGateway, publicSubdomainGateway, ipfs) - // Local gateway links for opening content in other apps on this machine. - // selectGatewayUrl honors the user's Local Gateway URL override. - const { localLink, subdomainLocalLink } = getLocalLinks(files, cid, gatewayUrl) + const linkOpts = { + namespace: 'ipfs', + pathId: cid.toString(), + subdomainLabel: cid.toV1().toString(), + filename, + localGatewayUrl: gatewayUrl, + publicGateway, + publicSubdomainGateway + } + + // The chosen link type drives what we copy. An unbuildable choice (e.g. a + // public type whose gateway was cleared) falls back to a native ipfs:// URI. + let type = store.selectEffectiveShareLinkType() + let link = buildShareLink({ type, ...linkOpts }) + if (!link) { + type = SHARE_LINK_TYPE.NATIVE + link = buildShareLink({ type, ...linkOpts }) + } + + // 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, gatewayUrl) - // Trigger background provide operation with the CID from getShareableLink dispatchAsyncProvide(cid, ipfs) - return { link: shareableLink, localLink, subdomainLocalLink } + return { link, type, localLink, subdomainLocalLink } }), /** diff --git a/src/bundles/files/protocol.ts b/src/bundles/files/protocol.ts index 9d0165057..bb4a39937 100644 --- a/src/bundles/files/protocol.ts +++ b/src/bundles/files/protocol.ts @@ -80,6 +80,7 @@ export type FileDownload = { export type DownloadLink = Perform<'FILES_DOWNLOADLINK', Error, FileDownload, void> export type ShareLinks = { link: string + type: string localLink: string subdomainLocalLink: string } diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js index d10166e18..4f9c65978 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,20 @@ 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(/\/+$/, '') 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 +199,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 +234,42 @@ 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 public type falls back to native when its + // public gateway is empty, so the two consumers (Share Link, Publish to IPNS) + // stay consistent with the disabled-option logic in Settings. + selectEffectiveShareLinkType: createSelector( + 'selectShareLinkType', + 'selectPublicGateway', + 'selectPublicSubdomainGateway', + (shareLinkType, publicGateway, publicSubdomainGateway) => + resolveEffectiveShareLinkType(shareLinkType, { publicGateway, publicSubdomainGateway }) + ) } export default bundle diff --git a/src/bundles/gateway.test.js b/src/bundles/gateway.test.js index 66c121035..cb0801a3e 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,84 @@ 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('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..d485ffe30 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 { safeSubresourceGwUrl } from '../lib/files.js' import { dispatchAsyncProvide } from './files/utils.js' // This bundle leverages createCacheBundle and persistActions for @@ -320,7 +322,16 @@ const pinningBundle = { selectPinningServices: (state) => state.pinning.pinningServices || [], - selectRemoteServiceTemplates: () => pinningServiceTemplates, + // 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. safeSubresourceGwUrl keeps a localhost gateway loading over + // 127.0.0.1 to avoid the subdomain-redirect breakage (issue 2246). + selectRemoteServiceTemplates: createSelector('selectAvailableGatewayUrl', (availableGatewayUrl) => + pinningServiceTemplates.map((template) => ({ + ...template, + icon: safeSubresourceGwUrl(`${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/local-gateway-form/LocalGatewayForm.js b/src/components/local-gateway-form/LocalGatewayForm.js index d8fdb0ca0..1854faef7 100644 --- a/src/components/local-gateway-form/LocalGatewayForm.js +++ b/src/components/local-gateway-form/LocalGatewayForm.js @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react' import { connect } from 'redux-bundler-react' import { withTranslation } from 'react-i18next' import Button from '../button/button.tsx' -import { checkValidHttpUrl, checkViaImgSrc } from '../../bundles/gateway.js' +import { checkValidHttpUrl } from '../../bundles/gateway.js' const LocalGatewayForm = ({ t, doUpdateLocalGateway, localGateway }) => { // Empty value is valid: it means "use the gateway address from Kubo config". @@ -18,19 +18,11 @@ const LocalGatewayForm = ({ t, doUpdateLocalGateway, localGateway }) => { const onChange = (event) => setValue(event.target.value) - const onSubmit = async (event) => { + const onSubmit = (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 - } - } + // We validate the URL format only and trust the user's choice, so a private + // or reverse-proxy gateway (the reason this override exists) is not rejected. doUpdateLocalGateway(value) } @@ -55,7 +47,7 @@ const LocalGatewayForm = ({ t, doUpdateLocalGateway, localGateway }) => { aria-label={t('terms.localGateway')} placeholder={t('localGatewayForm.placeholder', 'Enter a URL (http://localhost:8080)')} 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} onKeyPress={onKeyPress} value={value} @@ -75,7 +67,7 @@ const LocalGatewayForm = ({ t, doUpdateLocalGateway, localGateway }) => { id='local-gateway-submit-button' minWidth={100} height={40} - className='mt2 mt0-l ml2-l tc' + className='mt2 mt0-l ml2 tc' disabled={!isValid || !hasChanges}> {t('actions.submit')} diff --git a/src/components/public-gateway-form/PublicGatewayForm.js b/src/components/public-gateway-form/PublicGatewayForm.js index 35a0fff0d..a2308b45d 100644 --- a/src/components/public-gateway-form/PublicGatewayForm.js +++ b/src/components/public-gateway-form/PublicGatewayForm.js @@ -2,45 +2,33 @@ import React, { useState, useEffect } 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' +import { checkValidHttpUrl } from '../../bundles/gateway.js' const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => { + // We validate the URL format only and trust the user's choice, so a private or + // offline gateway is not rejected. Empty is valid: it clears the gateway, and + // Share Links for those CIDs fall back to a native ipfs:// URI. const [value, setValue] = useState(publicGateway) - const initialIsValidGatewayUrl = !checkValidHttpUrl(value) - const [showFailState, setShowFailState] = useState(initialIsValidGatewayUrl) - const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl) + const isValidGatewayUrl = value === '' || checkValidHttpUrl(value) + const [showFailState, setShowFailState] = useState(!isValidGatewayUrl) // 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) + setShowFailState(!(value === '' || checkValidHttpUrl(value))) }, [value]) const onChange = (event) => setValue(event.target.value) - const onSubmit = async (event) => { + const onSubmit = (event) => { event.preventDefault() - - try { - await checkViaImgSrc(value) - } catch (e) { - setShowFailState(true) - return - } - + if (!isValidGatewayUrl) return doUpdatePublicGateway(value) } - const onReset = async (event) => { + const onClear = async (event) => { event.preventDefault() - setValue(DEFAULT_PATH_GATEWAY) - doUpdatePublicGateway(DEFAULT_PATH_GATEWAY) + setValue('') + doUpdatePublicGateway('') } const onKeyPress = (event) => { @@ -56,27 +44,27 @@ const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => { aria-label={t('terms.publicGateway')} placeholder={t('publicGatewayForm.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} onKeyPress={onKeyPress} value={value} />
    diff --git a/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js b/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js index 92620c23c..6238b40a6 100644 --- a/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js +++ b/src/components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js @@ -2,49 +2,33 @@ import React, { useState, useEffect } 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' +import { checkValidHttpUrl } from '../../bundles/gateway.js' const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicSubdomainGateway }) => { + // We validate the URL format only and trust the user's choice, so a private or + // offline gateway is not rejected. Empty is valid: it clears the gateway, and + // Share Links fall back to a native ipfs:// URI. const [value, setValue] = useState(publicSubdomainGateway) - const initialIsValidGatewayUrl = !checkValidHttpUrl(value) - const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl) + const isValidGatewayUrl = value === '' || checkValidHttpUrl(value) + const [showFailState, setShowFailState] = useState(!isValidGatewayUrl) // 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() + setShowFailState(!(value === '' || checkValidHttpUrl(value))) }, [value]) const onChange = (event) => setValue(event.target.value) - const onSubmit = async (event) => { + const onSubmit = (event) => { event.preventDefault() - - let isValid = false - try { - isValid = await checkSubdomainGateway(value) - setIsValidGatewayUrl(true) - } catch (e) { - setIsValidGatewayUrl(false) - return - } - - isValid && doUpdatePublicSubdomainGateway(value) + if (!isValidGatewayUrl) return + doUpdatePublicSubdomainGateway(value) } - const onReset = async (event) => { + const onClear = (event) => { event.preventDefault() - setValue(DEFAULT_SUBDOMAIN_GATEWAY) - doUpdatePublicSubdomainGateway(DEFAULT_SUBDOMAIN_GATEWAY) + setValue('') + doUpdatePublicSubdomainGateway('') } const onKeyPress = (event) => { @@ -60,27 +44,27 @@ const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicS aria-label={t('terms.publicSubdomainGateway')} placeholder={t('publicSubdomainGatewayForm.placeholder')} type='text' - className={`w-100 lh-copy monospace f5 pl1 pv1 mb2 charcoal input-reset ba b--black-20 br1 ${!isValidGatewayUrl ? '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} onKeyPress={onKeyPress} value={value} />
    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..d70036e22 --- /dev/null +++ b/src/components/share-link-type-form/ShareLinkTypeForm.js @@ -0,0 +1,111 @@ +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, gatewayUrl, publicGateway, publicSubdomainGateway, doUpdateShareLinkType }) => { + // A public option cannot be chosen until its gateway is configured below it. + const disabledFor = { + [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]: gatewayUrl, + [SHARE_LINK_TYPE.LOCAL_SUBDOMAIN]: gatewayUrl, + [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', + 'selectGatewayUrl', + '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..8021bb8cd 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 { safeSubresourceGwUrl } from '../../lib/files.js' import ComponentLoader from '../../loader/ComponentLoader.js' import './FilePreview.css' import { CID } from 'multiformats/cid' @@ -161,13 +162,17 @@ const Preview = (props) => {

    {t('cantBePreviewed')} 😒

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

    @@ -238,19 +243,3 @@ export default connect( 'selectPublicGateway', 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..81add7979 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 { safeSubresourceGwUrl } from '../../lib/files.js' import './file-thumbnail.css' export interface FileThumbnailProps { @@ -40,7 +41,7 @@ const FileThumbnail: FC = ({ name, cid, availableGa } if (type === 'image') { - const src = `${availableGatewayUrl}/ipfs/${cid}?filename=${encodeURIComponent(name)}` + const src = safeSubresourceGwUrl(`${availableGatewayUrl}/ipfs/${cid}?filename=${encodeURIComponent(name)}`) return (
    diff --git a/src/files/modals/Modals.js b/src/files/modals/Modals.js index 1ac870bd2..433033f4a 100644 --- a/src/files/modals/Modals.js +++ b/src/files/modals/Modals.js @@ -64,6 +64,7 @@ class Modals extends React.Component { files: [] }, link: '', + shareLinkType: '', localLink: '', subdomainLocalLink: '', command: 'ipfs --help' @@ -134,6 +135,7 @@ class Modals extends React.Component { case SHARE: { this.setState({ link: t('generating'), + shareLinkType: '', localLink: '', subdomainLocalLink: '', readyToShow: true @@ -142,6 +144,7 @@ class Modals extends React.Component { onShareLink(files) .then(result => this.setState({ link: result.link, + shareLinkType: result.type, localLink: result.localLink, subdomainLocalLink: result.subdomainLocalLink })) @@ -258,7 +261,7 @@ class Modals extends React.Component { render () { const { show, t } = this.props - const { readyToShow, link, localLink, subdomainLocalLink, rename, command } = this.state + const { readyToShow, link, shareLinkType, localLink, subdomainLocalLink, rename, command } = this.state return (
    @@ -272,6 +275,7 @@ 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..6169d323f 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, gatewayUrl, 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,21 @@ 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, falling back to a native + // ipns:// URI when the selected type cannot be built (e.g. no gateway). + // The name is normalized to a base36 libp2p-key so subdomain and native + // links are valid. + const ipnsName = toIpnsBase36(selectedKey.id) + const ipnsLink = buildShareLink({ + type: effectiveShareLinkType, + namespace: 'ipns', + pathId: ipnsName, + subdomainLabel: ipnsName, + localGatewayUrl: gatewayUrl, + publicGateway, + publicSubdomainGateway + }) || `ipns://${ipnsName}` + setLink(ipnsLink) // Update the expected time with the new timing. const endTs = new Date().getTime() @@ -159,7 +174,10 @@ PublishModal.defaultProps = { export default connect( 'selectIpnsKeys', 'selectExpectedPublishTime', + 'selectGatewayUrl', '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..f260be04b 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', + gatewayUrl: '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 03f2657ff..e9d9dfdc9 100644 --- a/src/files/modals/share-modal/ShareModal.js +++ b/src/files/modals/share-modal/ShareModal.js @@ -3,11 +3,14 @@ import PropTypes from 'prop-types' import QRCode from 'react-qr-code' import Button from '../../../components/button/button.tsx' import Checkbox from '../../../components/checkbox/Checkbox.js' -import { withTranslation } from 'react-i18next' +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, localLink, subdomainLocalLink, className, ...props }) => { +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) @@ -21,18 +24,39 @@ const ShareModal = ({ t, tReady, onLeave, link, localLink, subdomainLocalLink, c } } + 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 (useLocalLink && localLink) { + 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 ( -

    - {useLocalLink ? t('shareModal.descriptionLocal') : t('shareModal.description')} -

    - {!useLocalLink && ( +

    {description}

    + {showQRCode && (
    - {localLink && ( + {offerLocalLink && (
    )} - {useLocalLink && subdomainLocalLink && ( + {offerLocalLink && useLocalLink && subdomainLocalLink && (
    (
    diff --git a/src/lib/files.js b/src/lib/files.js index 84ab6d547..8b7992669 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -28,6 +28,27 @@ export function normalizeFiles (files) { return streams } +/** + * Fix for the mixed-content error when loading from a localhost gateway, see + * https://github.com/ipfs/ipfs-webui/issues/2246 + * + * localhost in Kubo is a subdomain gateway, so http://localhost:8080/ipfs/cid + * redirects to http://cid.ipfs.localhost:8080. Some browsers do not treat that + * subdomain as a secure context and force-upgrade it to https, which breaks the + * load; switching to the IP avoids the redirect. + * + * Applied at each load site (file previews, thumbnails, downloads, CAR links) + * rather than globally in config.js. + * + * @param {string} url - a gateway URL, or a full gateway content URL + * @returns {string} + */ +export function safeSubresourceGwUrl (url) { + // Match http://localhost with any port (or none), but not https or a host + // like localhostx; the lookahead requires a port, path, or end of string. + return url.replace(/^http:\/\/localhost(?=[:/]|$)/, 'http://127.0.0.1') +} + /** * @param {string} type * @param {string} name @@ -78,6 +99,7 @@ export async function makeCIDFromFiles (files, ipfs) { * @returns {Promise} */ export async function getDownloadLink (files, gatewayUrl, ipfs) { + gatewayUrl = safeSubresourceGwUrl(gatewayUrl) if (files.length === 1) { return getDownloadURL(files[0].type, files[0].name, files[0].cid, gatewayUrl) } @@ -87,107 +109,15 @@ export async function getDownloadLink (files, gatewayUrl, ipfs) { } /** - * 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. + * 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 - * @returns {string} the query string, or '' when no filename hint applies - */ -function getFilenameQuery (files) { - if (files.length === 1 && files[0].type === 'file') { - return `?filename=${encodeURIComponent(files[0].name)}` - } - return '' -} - -// Loopback hosts, matched against a URL hostname (IPv6 keeps its brackets). They -// all reach the same local node, so its links use canonical forms: the IP for -// the path link (no DNS needed) and localhost for the subdomain link (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_IP = '127.0.0.1' -const LOOPBACK_HOSTNAME = 'localhost' - -/** - * 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) -} - -/** - * Generates a shareable link for the provided files using a subdomain gateway as default or a path gateway as fallback. - * - * @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. - */ -export async function getShareableLink (files, gatewayUrl, subdomainGatewayUrl, ipfs) { - const cid = files.length === 1 ? files[0].cid : await makeCIDFromFiles(files, ipfs) - const filename = getFilenameQuery(files) - 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. - */ - const base32Cid = cid.toV1().toString() - const shareableLink = base32Cid.length < 64 - ? `${url.protocol}//${base32Cid}.ipfs.${url.host}${filename}` - : `${gatewayUrl}/ipfs/${cid}${filename}` - - return { link: shareableLink, cid } -} - -/** - * Build local gateway links for opening content in other apps on the same - * machine, honoring the user's Local Gateway URL override. - * - * For a loopback gateway (localhost, 127.0.0.1, ...) the two links use canonical - * forms: the path link uses 127.0.0.1 (no DNS needed) and the subdomain link - * uses localhost (subdomain origins need a hostname). A real domain gateway - * keeps its host for both. A non-loopback IP gets no subdomain link, and neither - * does a CIDv1 too long for a 63-character DNS label; subdomainLocalLink is ''. - * - * @param {FileStat[]} files - * @param {CID} cid - root CID, already resolved by getShareableLink - * @param {string} gatewayUrl - the local gateway URL - * @returns {{localLink: string, subdomainLocalLink: string}} + * @param {IPFSService} ipfs + * @returns {Promise} */ -export function getLocalLinks (files, cid, gatewayUrl) { - const filename = getFilenameQuery(files) - const url = new URL(gatewayUrl) - const isLoopback = LOOPBACK_HOSTNAMES.has(url.hostname) - const port = url.port ? `:${url.port}` : '' - - const localLink = isLoopback - ? `${url.protocol}//${LOOPBACK_IP}${port}/ipfs/${cid}${filename}` - : `${gatewayUrl}/ipfs/${cid}${filename}` - - const base32Cid = cid.toV1().toString() - let subdomainLocalLink = '' - if (base32Cid.length < 64) { - if (isLoopback) { - subdomainLocalLink = `${url.protocol}//${base32Cid}.ipfs.${LOOPBACK_HOSTNAME}${port}${filename}` - } else if (!isIpHostname(url.hostname)) { - subdomainLocalLink = `${url.protocol}//${base32Cid}.ipfs.${url.host}${filename}` - } - } - - return { localLink, subdomainLocalLink } +export async function resolveShareCid (files, ipfs) { + return files.length === 1 ? files[0].cid : makeCIDFromFiles(files, ipfs) } /** @@ -198,6 +128,7 @@ export function getLocalLinks (files, cid, gatewayUrl) { * @returns {Promise} */ export async function getCarLink (files, gatewayUrl, ipfs) { + gatewayUrl = safeSubresourceGwUrl(gatewayUrl) let cid, filename if (files.length === 1) { diff --git a/src/lib/files.test.js b/src/lib/files.test.js index 5410b2911..b2aa9fdd3 100644 --- a/src/lib/files.test.js +++ b/src/lib/files.test.js @@ -1,7 +1,6 @@ /* global it, expect */ -import { normalizeFiles, getShareableLink, getLocalLinks } from './files.js' -import { DEFAULT_SUBDOMAIN_GATEWAY, DEFAULT_PATH_GATEWAY } from '../bundles/gateway.js' import { CID } from 'multiformats/cid' +import { normalizeFiles, safeSubresourceGwUrl, getDownloadLink, getCarLink } from './files.js' function expectRightFormat (output) { expect(Array.isArray(output)).toBe(true) @@ -252,83 +251,26 @@ 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() -}) - -const SHORT_CID = CID.parse('QmZTR5bcpQD7cFgTorqxZDYaew1Wqgfbd2ud9QqGPAkK2V') -const SHORT_CID_BASE32 = 'bafybeifffq3aeaymxejo37sn5fyaf7nn7hkfmzwdxyjculx3lw4tyhk7uy' - -it('getLocalLinks gives loopback gateways an IP path link and a localhost subdomain link', () => { - // localhost, 127.0.0.1 and [::1] are the same local node: the path link uses - // the IP (no DNS) and the subdomain link uses localhost (origins need a name). - for (const gateway of ['http://localhost:8080', 'http://127.0.0.1:8080', 'http://[::1]:8080']) { - const files = [{ cid: SHORT_CID, name: 'example.txt', type: 'file' }] - const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, gateway) - - expect(localLink).toBe(`http://127.0.0.1:8080/ipfs/${SHORT_CID}?filename=example.txt`) - expect(subdomainLocalLink).toBe(`http://${SHORT_CID_BASE32}.ipfs.localhost:8080?filename=example.txt`) - } -}) - -it('getLocalLinks leaves a non-loopback domain gateway unchanged', () => { - const files = [{ cid: SHORT_CID, name: 'example.txt', type: 'file' }] - const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'https://gw.example.com') - - expect(localLink).toBe(`https://gw.example.com/ipfs/${SHORT_CID}?filename=example.txt`) - expect(subdomainLocalLink).toBe(`https://${SHORT_CID_BASE32}.ipfs.gw.example.com?filename=example.txt`) +it('safeSubresourceGwUrl rewrites http localhost to 127.0.0.1', () => { + expect(safeSubresourceGwUrl('http://localhost:8080/ipfs/bafy?filename=a.txt')).toBe('http://127.0.0.1:8080/ipfs/bafy?filename=a.txt') }) -it('getLocalLinks omits the subdomain link for a non-loopback IP gateway', () => { - const files = [{ cid: SHORT_CID, name: 'example.txt', type: 'file' }] - const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'http://192.168.1.5:8080') - - expect(localLink).toBe(`http://192.168.1.5:8080/ipfs/${SHORT_CID}?filename=example.txt`) - expect(subdomainLocalLink).toBe('') +it('safeSubresourceGwUrl rewrites http localhost on the default port', () => { + expect(safeSubresourceGwUrl('http://localhost/ipfs/bafy')).toBe('http://127.0.0.1/ipfs/bafy') }) -it('getLocalLinks omits the subdomain link when the CID exceeds a DNS label', () => { - const longCid = CID.parse('bagaaifcavabu6fzheerrmtxbbwv7jjhc3kaldmm7lbnvfopyrthcvod4m6ygpj3unrcggkzhvcwv5wnhc5ufkgzlsji7agnmofovc2g4a3ui7ja') - const files = [{ cid: longCid, name: 'example.txt', type: 'file' }] - const { localLink, subdomainLocalLink } = getLocalLinks(files, longCid, 'http://localhost:8080') - - expect(localLink).toBe(`http://127.0.0.1:8080/ipfs/${longCid}?filename=example.txt`) - expect(subdomainLocalLink).toBe('') +it('safeSubresourceGwUrl leaves other hosts and schemes unchanged', () => { + expect(safeSubresourceGwUrl('http://127.0.0.1:8080/ipfs/bafy')).toBe('http://127.0.0.1:8080/ipfs/bafy') + expect(safeSubresourceGwUrl('https://dweb.link/ipfs/bafy')).toBe('https://dweb.link/ipfs/bafy') + expect(safeSubresourceGwUrl('https://localhost:8080/ipfs/bafy')).toBe('https://localhost:8080/ipfs/bafy') + expect(safeSubresourceGwUrl('http://localhostx:8080/ipfs/bafy')).toBe('http://localhostx:8080/ipfs/bafy') }) -it('getLocalLinks adds no filename query for a directory', () => { - const files = [{ cid: SHORT_CID, name: 'a-directory', type: 'directory' }] - const { localLink, subdomainLocalLink } = getLocalLinks(files, SHORT_CID, 'http://localhost:8080') - - expect(localLink).toBe(`http://127.0.0.1:8080/ipfs/${SHORT_CID}`) - expect(subdomainLocalLink).toBe(`http://${SHORT_CID_BASE32}.ipfs.localhost:8080`) +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..ec1cf4627 --- /dev/null +++ b/src/lib/share-link.js @@ -0,0 +1,245 @@ +// 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 (no DNS needed) 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_IP = '127.0.0.1' +const LOOPBACK_HOSTNAME = 'localhost' + +/** + * 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 a loopback host to 127.0.0.1 + * @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 && LOOPBACK_HOSTNAMES.has(url.hostname)) { + const port = url.port ? `:${url.port}` : '' + host = `${LOOPBACK_IP}${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: `