From fa7960edb21ba55dd8e581974fbe1b38027c58d7 Mon Sep 17 00:00:00 2001 From: Nilton Volpato Date: Wed, 1 Apr 2026 07:24:30 -0700 Subject: [PATCH 1/5] feat: add Local Gateway URL setting for reverse proxy support Adds a new 'Local Gateway URL' setting that allows users to override the gateway address from Kubo config. This is useful when: - Running Kubo in Docker - Accessing WebUI through a reverse proxy - Accessing from a different host than where Kubo runs The setting takes priority over the Kubo config gateway address. When empty, the behavior falls back to the existing logic. Fixes #2458 --- public/locales/en/app.json | 5 ++ src/bundles/config.js | 6 +- src/bundles/gateway.js | 31 ++++++- .../local-gateway-form/LocalGatewayForm.js | 82 +++++++++++++++++++ src/settings/SettingsPage.js | 29 ++++--- 5 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 src/components/local-gateway-form/LocalGatewayForm.js diff --git a/public/locales/en/app.json b/public/locales/en/app.json index 9bd5a1339..a55088b68 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -48,6 +48,10 @@ "publicGatewayForm": { "placeholder": "Enter a URL (https://ipfs.io)" }, + "localGatewayForm": { + "placeholder": "Enter a URL (https://ipfs.example.com)", + "description": "Set this to your gateway URL if accessing WebUI through a reverse proxy or from a different host. Leave empty to use the gateway address from Kubo config." + }, "publicSubdomainGatewayForm": { "placeholder": "Enter a URL (https://dweb.link)" }, @@ -87,6 +91,7 @@ "pinStatus": "Pin Status", "publicKey": "Public key", "publicGateway": "Public Gateway", + "localGateway": "Local Gateway", "rateIn": "Rate in", "rateOut": "Rate out", "repo": "Repo", diff --git a/src/bundles/config.js b/src/bundles/config.js index e4902fc7c..90646ad03 100644 --- a/src/bundles/config.js +++ b/src/bundles/config.js @@ -66,7 +66,11 @@ bundle.reactIsSameOriginToBridge = createSelector( bundle.selectGatewayUrl = createSelector( 'selectConfigObject', 'selectPublicGateway', - (config, publicGateway) => getURLFromAddress('Gateway', config) || publicGateway + 'selectLocalGateway', + (config, publicGateway, localGateway) => { + // Priority: 1) User-configured local gateway, 2) Kubo config, 3) Public gateway + return localGateway || getURLFromAddress('Gateway', config) || publicGateway + } ) bundle.selectAvailableGatewayUrl = createSelector( diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js index fa8019bf1..b09eba189 100644 --- a/src/bundles/gateway.js +++ b/src/bundles/gateway.js @@ -19,6 +19,13 @@ const readPublicGatewaySetting = () => { return setting || DEFAULT_PATH_GATEWAY } +const readLocalGatewaySetting = () => { + const setting = readSetting('ipfsLocalGateway') + // Return empty string if not set, so we can distinguish between + // "not configured" and "configured to empty" + return setting || '' +} + const readPublicSubdomainGatewaySetting = () => { const setting = readSetting('ipfsPublicSubdomainGateway') return setting || DEFAULT_SUBDOMAIN_GATEWAY @@ -33,7 +40,8 @@ const init = () => ({ availableGateway: null, publicGateway: readPublicGatewaySetting(), publicSubdomainGateway: readPublicSubdomainGatewaySetting(), - ipfsCheckUrl: readIpfsCheckUrlSetting() + ipfsCheckUrl: readIpfsCheckUrlSetting(), + localGateway: readLocalGatewaySetting() }) /** @@ -207,6 +215,10 @@ const bundle = { return { ...state, ipfsCheckUrl: action.payload } } + if (action.type === 'SET_LOCAL_GATEWAY') { + return { ...state, localGateway: action.payload } + } + return state }, @@ -243,6 +255,15 @@ const bundle = { dispatch({ type: 'SET_IPFS_CHECK_URL', payload: url }) }, + /** + * @param {string} address + * @returns {function({dispatch: Function}): Promise} + */ + doUpdateLocalGateway: (address) => async ({ dispatch }) => { + await writeSetting('ipfsLocalGateway', address) + dispatch({ type: 'SET_LOCAL_GATEWAY', payload: address }) + }, + /** * @param {any} state * @returns {string|null} @@ -265,7 +286,13 @@ const bundle = { * @param {any} state * @returns {string} */ - selectIpfsCheckUrl: (state) => state?.gateway?.ipfsCheckUrl + selectIpfsCheckUrl: (state) => state?.gateway?.ipfsCheckUrl, + + /** + * @param {any} state + * @returns {string} + */ + selectLocalGateway: (state) => state?.gateway?.localGateway } export default bundle diff --git a/src/components/local-gateway-form/LocalGatewayForm.js b/src/components/local-gateway-form/LocalGatewayForm.js new file mode 100644 index 000000000..412b7b8c7 --- /dev/null +++ b/src/components/local-gateway-form/LocalGatewayForm.js @@ -0,0 +1,82 @@ +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 } from '../../bundles/gateway.js' + +const LocalGatewayForm = ({ t, doUpdateLocalGateway, localGateway }) => { + const [value, setValue] = useState(localGateway) + const [isValid, setIsValid] = useState(true) + + useEffect(() => { + // Empty value is valid (means "use default from Kubo config") + setIsValid(value === '' || checkValidHttpUrl(value)) + }, [value]) + + const onChange = (event) => setValue(event.target.value) + + const onSubmit = async (event) => { + event.preventDefault() + if (isValid) { + doUpdateLocalGateway(value) + } + } + + const onClear = async (event) => { + event.preventDefault() + setValue('') + doUpdateLocalGateway('') + } + + const onKeyPress = (event) => { + if (event.key === 'Enter') { + onSubmit(event) + } + } + + const hasChanges = value !== localGateway + + return ( +
+ +
+ + +
+

+ {t('localGatewayForm.description', 'Set this to your gateway URL if accessing WebUI through a reverse proxy or from a different host. Leave empty to use the gateway address from Kubo config.')} +

+
+ ) +} + +export default connect( + 'doUpdateLocalGateway', + 'selectLocalGateway', + withTranslation('app')(LocalGatewayForm) +) diff --git a/src/settings/SettingsPage.js b/src/settings/SettingsPage.js index a7c4e48b6..ccb05230f 100644 --- a/src/settings/SettingsPage.js +++ b/src/settings/SettingsPage.js @@ -18,6 +18,7 @@ import AnalyticsToggle from '../components/analytics-toggle/AnalyticsToggle.js' import ApiAddressForm from '../components/api-address-form/api-address-form' import PublicGatewayForm from '../components/public-gateway-form/PublicGatewayForm.js' import PublicSubdomainGatewayForm from '../components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js' +import LocalGatewayForm from '../components/local-gateway-form/LocalGatewayForm.js' import IpfsCheckForm from '../components/ipfs-check-form/IpfsCheckForm.js' import { JsonEditor } from './editor/JsonEditor.js' import Experiments from '../components/experiments/ExperimentsPanel.js' @@ -67,19 +68,23 @@ export const SettingsPage = ({
+ {t('app:terms.localGateway')} + +
+
{t('app:terms.publicGateway')} - -

Select a default Subdomain Gateway for generating shareable links.

-
- -
-
- -

Select a fallback Path Gateway for generating shareable links for CIDs that exceed the 63-character DNS limit.

-
- -
-
+ +

Select a default Subdomain Gateway for generating shareable links.

+
+ + +
+ +

Select a fallback Path Gateway for generating shareable links for CIDs that exceed the 63-character DNS limit.

+
+ +
+ {t('ipnsPublishingKeys.title')} From f668e384bb821a725b52fd78c9de0e1f87d5b858 Mon Sep 17 00:00:00 2001 From: Nilton Volpato Date: Wed, 1 Apr 2026 07:34:41 -0700 Subject: [PATCH 2/5] fix: normalize gateway URLs by stripping trailing slashes Ensures URLs like 'https://example.com/' and 'https://example.com' are handled the same way, avoiding double slashes when constructing paths like /ipfs/CID. --- src/bundles/config.js | 4 +++- src/bundles/gateway.js | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/bundles/config.js b/src/bundles/config.js index 90646ad03..1b9bfa803 100644 --- a/src/bundles/config.js +++ b/src/bundles/config.js @@ -69,7 +69,9 @@ bundle.selectGatewayUrl = createSelector( 'selectLocalGateway', (config, publicGateway, localGateway) => { // Priority: 1) User-configured local gateway, 2) Kubo config, 3) Public gateway - return localGateway || getURLFromAddress('Gateway', config) || publicGateway + const url = localGateway || getURLFromAddress('Gateway', config) || publicGateway + // Normalize: remove trailing slashes to avoid double slashes when constructing paths + return url.replace(/\/+$/, '') } ) diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js index b09eba189..48f7351d6 100644 --- a/src/bundles/gateway.js +++ b/src/bundles/gateway.js @@ -260,8 +260,10 @@ const bundle = { * @returns {function({dispatch: Function}): Promise} */ doUpdateLocalGateway: (address) => async ({ dispatch }) => { - await writeSetting('ipfsLocalGateway', address) - dispatch({ type: 'SET_LOCAL_GATEWAY', payload: address }) + // Normalize: remove trailing slashes + const normalizedAddress = address.replace(/\/+$/, '') + await writeSetting('ipfsLocalGateway', normalizedAddress) + dispatch({ type: 'SET_LOCAL_GATEWAY', payload: normalizedAddress }) }, /** From f9f6f56fd682b486590a43d3fe2f4d8315f306ea Mon Sep 17 00:00:00 2001 From: Nilton Volpato Date: Wed, 1 Apr 2026 07:50:54 -0700 Subject: [PATCH 3/5] fix: sync local gateway setting to kuboGateway for Helia/Explore The ipld-explorer-components (Explore page) uses localStorage key 'kuboGateway' with {host, port, protocol} format. This change syncs our 'ipfsLocalGateway' setting to that format so the Explore page also uses the correct gateway URL. Fixes Explore page using 127.0.0.1:8080 instead of custom gateway. --- src/bundles/gateway.js | 18 ++++++++++++++++++ src/bundles/ipfs-provider.js | 20 +++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js index 48f7351d6..990ac8be7 100644 --- a/src/bundles/gateway.js +++ b/src/bundles/gateway.js @@ -264,6 +264,24 @@ const bundle = { const normalizedAddress = address.replace(/\/+$/, '') await writeSetting('ipfsLocalGateway', normalizedAddress) dispatch({ type: 'SET_LOCAL_GATEWAY', payload: normalizedAddress }) + + // Sync to kuboGateway for Helia/Explore components + if (normalizedAddress) { + try { + const url = new URL(normalizedAddress) + const host = url.hostname + const port = url.port || (url.protocol === 'https:' ? '443' : '80') + const protocol = url.protocol.replace(':', '') + await writeSetting('kuboGateway', { + host, + port, + protocol, + trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: protocol === 'http' } } + }) + } catch (e) { + console.error('Error syncing ipfsLocalGateway to kuboGateway:', e) + } + } }, /** diff --git a/src/bundles/ipfs-provider.js b/src/bundles/ipfs-provider.js index 7b4791087..314a4f2a6 100644 --- a/src/bundles/ipfs-provider.js +++ b/src/bundles/ipfs-provider.js @@ -332,7 +332,25 @@ const actions = { } const kuboGateway = readSetting('kuboGateway') - if (kuboGateway === null || typeof kuboGateway === 'string' || typeof kuboGateway === 'boolean' || typeof kuboGateway === 'number') { + const localGateway = readSetting('ipfsLocalGateway') + + if (localGateway) { + // User has configured a custom local gateway, sync it to kuboGateway for Helia/Explore + try { + const url = new URL(localGateway) + const host = url.hostname + const port = url.port || (url.protocol === 'https:' ? '443' : '80') + const protocol = url.protocol.replace(':', '') + await writeSetting('kuboGateway', { + host, + port, + protocol, + trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: protocol === 'http' } } + }) + } catch (e) { + console.error('Error parsing ipfsLocalGateway for kuboGateway:', e) + } + } else if (kuboGateway === null || typeof kuboGateway === 'string' || typeof kuboGateway === 'boolean' || typeof kuboGateway === 'number') { // empty or invalid, set defaults await writeSetting('kuboGateway', { trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: false } } }) } else if (/** @type {Record} */(kuboGateway).trustlessBlockBrokerConfig == null) { From 4e847d0e3b421faf60ebc1c1e3d411f3cb42877d Mon Sep 17 00:00:00 2001 From: Nilton Volpato Date: Wed, 1 Apr 2026 08:36:14 -0700 Subject: [PATCH 4/5] fix: avoid 'address' in localGatewayForm description to prevent test flakiness The e2e test uses getByText('Addresses') which matches any element containing 'address' (case-insensitive). Changed 'gateway address' to 'gateway URL' in the description to avoid matching this query. --- public/locales/en/app.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/locales/en/app.json b/public/locales/en/app.json index a55088b68..1a52ff7d1 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -50,7 +50,7 @@ }, "localGatewayForm": { "placeholder": "Enter a URL (https://ipfs.example.com)", - "description": "Set this to your gateway URL if accessing WebUI through a reverse proxy or from a different host. Leave empty to use the gateway address from Kubo config." + "description": "Set this to your gateway URL if accessing WebUI through a reverse proxy or from a different host. Leave empty to use the gateway URL from Kubo config." }, "publicSubdomainGatewayForm": { "placeholder": "Enter a URL (https://dweb.link)" From 83e21d56644827611a57e8ca6eda56268dbd1d89 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Tue, 30 Jun 2026 03:13:53 +0200 Subject: [PATCH 5/5] fix: apply local gateway override everywhere The Local Gateway URL only reached download links, so previews, thumbnails and IPNS links still used the public gateway behind a reverse proxy. Make the override apply to every gateway link the WebUI builds. - config: when an override is set, use it as the reachable gateway too, so previews, thumbnails, Explore and IPNS links honor it - ipns-manager: use the available gateway URL, which also fixes a broken link before the gateway check has run - gateway/ipfs-provider: one shared helper builds the Explore (Helia) gateway config from the override, and clearing the override resets it - local-gateway-form: check the URL loads before saving so a typo cannot silently break links; the check now keeps the port - settings: show the description above the input like the other gateway fields, link the gateway address to its Kubo docs, and use `http://localhost:8080` in the example - tests: unit coverage for the override selection and the Helia config helper, plus an e2e that sets a Local Gateway URL and confirms it on the Status page Closes #2383 --- public/locales/en/app.json | 3 +- public/locales/en/settings.json | 1 + src/bundles/config.js | 12 +++++ src/bundles/config.test.js | 54 +++++++++++++++++++ src/bundles/gateway.js | 45 +++++++++++----- src/bundles/gateway.test.js | 43 +++++++++++++++ src/bundles/ipfs-provider.js | 18 ++----- src/components/ipns-manager/IpnsManager.js | 6 +-- .../local-gateway-form/LocalGatewayForm.js | 31 +++++++---- src/settings/SettingsPage.js | 5 +- test/e2e/settings.test.js | 36 +++++++++++++ test/e2e/setup/global-setup.js | 1 + 12 files changed, 213 insertions(+), 42 deletions(-) create mode 100644 src/bundles/config.test.js create mode 100644 src/bundles/gateway.test.js diff --git a/public/locales/en/app.json b/public/locales/en/app.json index 1a52ff7d1..7a1a35798 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -49,8 +49,7 @@ "placeholder": "Enter a URL (https://ipfs.io)" }, "localGatewayForm": { - "placeholder": "Enter a URL (https://ipfs.example.com)", - "description": "Set this to your gateway URL if accessing WebUI through a reverse proxy or from a different host. Leave empty to use the gateway URL from Kubo config." + "placeholder": "Enter a URL (http://localhost:8080)" }, "publicSubdomainGatewayForm": { "placeholder": "Enter a URL (https://dweb.link)" diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 98585c8e3..9c320f9cd 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -23,6 +23,7 @@ "translationProjectLink": "Join the IPFS Translation Project" }, "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.", "retrievalDiagnosticService": { diff --git a/src/bundles/config.js b/src/bundles/config.js index 1b9bfa803..049a54367 100644 --- a/src/bundles/config.js +++ b/src/bundles/config.js @@ -23,6 +23,18 @@ 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. + // 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 diff --git a/src/bundles/config.test.js b/src/bundles/config.test.js new file mode 100644 index 000000000..195beb788 --- /dev/null +++ b/src/bundles/config.test.js @@ -0,0 +1,54 @@ +/* global describe, it, expect, beforeEach, afterEach */ +import { jest } from '@jest/globals' +import { composeBundles } from 'redux-bundler' +import configBundle from './config.js' +import gatewayBundle from './gateway.js' + +// selectIpfsReady is false so the reactConfigFetch reactor stays quiet and we +// drive doFetchConfig (which runs getPromise) deterministically from the test. +const createMockIpfsBundle = (config) => ({ + name: 'ipfs', + getExtraArgs: () => ({ getIpfs: () => ({ config: { getAll: async () => config } }) }), + selectIpfsReady: () => false, + selectIpfsConnected: () => false +}) + +const createStore = (config) => composeBundles( + createMockIpfsBundle(config), + gatewayBundle, + configBundle +)() + +describe('gateway selection with a Local Gateway URL override', () => { + // getURLFromAddress logs when @multiformats/multiaddr-to-uri cannot resolve a + // multiaddr, which it cannot under jest; the override path does not need it. + let logSpy + beforeEach(() => { + window.localStorage.clear() + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}) + }) + afterEach(() => logSpy.mockRestore()) + + // https://github.com/ipfs/ipfs-webui/issues/2458 + it('routes the configured and available gateway through the override, and falls back to the Kubo config gateway when cleared', async () => { + const store = createStore({ Addresses: { Gateway: '/ip4/127.0.0.1/tcp/8080' } }) + + // trailing slash is normalized away on save + await store.doUpdateLocalGateway('https://ipfs.example.com/') + await store.doFetchConfig() + + expect(store.selectLocalGateway()).toBe('https://ipfs.example.com') + // override beats the Kubo config gateway for download links + expect(store.selectGatewayUrl()).toBe('https://ipfs.example.com') + // getPromise sets availableGateway to the override, so previews, thumbnails + // and IPNS links (which use selectAvailableGateway*) honor it too + expect(store.selectAvailableGateway()).toBe('https://ipfs.example.com') + expect(store.selectAvailableGatewayUrl()).toBe('https://ipfs.example.com') + + // clearing the override removes it from gateway selection (the multiaddr + // ->URI fallback to the Kubo config gateway is covered by e2e, since + // @multiformats/multiaddr-to-uri does not load under jest) + await store.doUpdateLocalGateway('') + expect(store.selectLocalGateway()).toBe('') + }) +}) diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js index 990ac8be7..d10166e18 100644 --- a/src/bundles/gateway.js +++ b/src/bundles/gateway.js @@ -59,6 +59,31 @@ export const checkValidHttpUrl = (value) => { return url.protocol === 'http:' || url.protocol === 'https:' } +/** + * Default `kuboGateway` config consumed by Helia/verified-fetch + * (ipld-explorer-components) on the Explore page when no explicit Local Gateway + * URL is set. + */ +export const DEFAULT_KUBO_GATEWAY = { trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: false } } } + +/** + * Convert a Local Gateway URL into the `kuboGateway` config shape consumed by + * Helia/verified-fetch on the Explore page, so the explorer fetches blocks from + * the same gateway the rest of the WebUI uses. + * @param {string} gatewayUrl + * @returns {{host: string, port: string, protocol: string, trustlessBlockBrokerConfig: object}} + */ +export const localGatewayToKuboGateway = (gatewayUrl) => { + const url = new URL(gatewayUrl) + const protocol = url.protocol.replace(':', '') + return { + host: url.hostname, + port: url.port || (url.protocol === 'https:' ? '443' : '80'), + protocol, + trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: protocol === 'http' } } + } +} + /** * Check if any hashes from IMG_ARRAY can be loaded from the provided gatewayUrl * @param {string} gatewayUrl - The gateway URL to check @@ -79,7 +104,9 @@ export const checkViaImgSrc = (gatewayUrl) => { */ // @ts-expect-error - Promise.any requires ES2021 but we're on ES2020 return Promise.any(IMG_ARRAY.map(element => { - const imgUrl = new URL(`${url.protocol}//${url.hostname}/ipfs/${element.hash}?now=${Date.now()}&filename=${element.name}#x-ipfs-companion-no-redirect`) + // 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) })) } @@ -265,22 +292,16 @@ const bundle = { await writeSetting('ipfsLocalGateway', normalizedAddress) dispatch({ type: 'SET_LOCAL_GATEWAY', payload: normalizedAddress }) - // Sync to kuboGateway for Helia/Explore components + // Keep kuboGateway (used by Helia/Explore) in sync with the override. if (normalizedAddress) { try { - const url = new URL(normalizedAddress) - const host = url.hostname - const port = url.port || (url.protocol === 'https:' ? '443' : '80') - const protocol = url.protocol.replace(':', '') - await writeSetting('kuboGateway', { - host, - port, - protocol, - trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: protocol === 'http' } } - }) + await writeSetting('kuboGateway', localGatewayToKuboGateway(normalizedAddress)) } catch (e) { console.error('Error syncing ipfsLocalGateway to kuboGateway:', e) } + } else { + // Override cleared: restore defaults so Explore stops using the old host. + await writeSetting('kuboGateway', DEFAULT_KUBO_GATEWAY) } }, diff --git a/src/bundles/gateway.test.js b/src/bundles/gateway.test.js new file mode 100644 index 000000000..66c121035 --- /dev/null +++ b/src/bundles/gateway.test.js @@ -0,0 +1,43 @@ +/* global describe, it, expect */ +import { localGatewayToKuboGateway, checkValidHttpUrl } from './gateway.js' + +describe('localGatewayToKuboGateway', () => { + it('keeps an explicit port and treats http as insecure', () => { + expect(localGatewayToKuboGateway('http://127.0.0.1:8080')).toEqual({ + host: '127.0.0.1', + port: '8080', + protocol: 'http', + trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: true } } + }) + }) + + it('defaults the port to 443 for https and keeps it secure', () => { + expect(localGatewayToKuboGateway('https://ipfs.example.com')).toEqual({ + host: 'ipfs.example.com', + port: '443', + protocol: 'https', + trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: false } } + }) + }) + + it('defaults the port to 80 for http without an explicit port', () => { + expect(localGatewayToKuboGateway('http://gateway.local').port).toBe('80') + }) + + it('throws on an invalid URL', () => { + expect(() => localGatewayToKuboGateway('not a url')).toThrow() + }) +}) + +describe('checkValidHttpUrl', () => { + it('accepts http and https URLs, including non-default ports', () => { + expect(checkValidHttpUrl('http://127.0.0.1:8080')).toBe(true) + expect(checkValidHttpUrl('https://ipfs.example.com')).toBe(true) + }) + + it('rejects non-http(s) and malformed values', () => { + expect(checkValidHttpUrl('ftp://example.com')).toBe(false) + expect(checkValidHttpUrl('not a url')).toBe(false) + expect(checkValidHttpUrl('')).toBe(false) + }) +}) diff --git a/src/bundles/ipfs-provider.js b/src/bundles/ipfs-provider.js index 314a4f2a6..3de1554b6 100644 --- a/src/bundles/ipfs-provider.js +++ b/src/bundles/ipfs-provider.js @@ -6,6 +6,7 @@ import last from 'it-last' import * as Enum from '../lib/enum.js' import { perform } from './task.js' import { readSetting, writeSetting } from './local-storage.js' +import { localGatewayToKuboGateway, DEFAULT_KUBO_GATEWAY } from './gateway.js' import { contextBridge } from '../helpers/context-bridge' import { createSelector } from 'redux-bundler' @@ -334,28 +335,19 @@ const actions = { const kuboGateway = readSetting('kuboGateway') const localGateway = readSetting('ipfsLocalGateway') - if (localGateway) { + if (typeof localGateway === 'string' && localGateway) { // User has configured a custom local gateway, sync it to kuboGateway for Helia/Explore try { - const url = new URL(localGateway) - const host = url.hostname - const port = url.port || (url.protocol === 'https:' ? '443' : '80') - const protocol = url.protocol.replace(':', '') - await writeSetting('kuboGateway', { - host, - port, - protocol, - trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: protocol === 'http' } } - }) + await writeSetting('kuboGateway', localGatewayToKuboGateway(localGateway)) } catch (e) { console.error('Error parsing ipfsLocalGateway for kuboGateway:', e) } } else if (kuboGateway === null || typeof kuboGateway === 'string' || typeof kuboGateway === 'boolean' || typeof kuboGateway === 'number') { // empty or invalid, set defaults - await writeSetting('kuboGateway', { trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: false } } }) + await writeSetting('kuboGateway', DEFAULT_KUBO_GATEWAY) } else if (/** @type {Record} */(kuboGateway).trustlessBlockBrokerConfig == null) { // missing trustlessBlockBrokerConfig, set defaults - await writeSetting('kuboGateway', { ...kuboGateway, trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: false } } }) + await writeSetting('kuboGateway', { ...kuboGateway, ...DEFAULT_KUBO_GATEWAY }) } }, diff --git a/src/components/ipns-manager/IpnsManager.js b/src/components/ipns-manager/IpnsManager.js index 25fee8baa..b5538b457 100644 --- a/src/components/ipns-manager/IpnsManager.js +++ b/src/components/ipns-manager/IpnsManager.js @@ -53,7 +53,7 @@ const OptionsCell = ({ t, name, showRenameKeyModal, showRemoveKeyModal }) => { ) } -export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey, doRenameIpnsKey, doRemoveIpnsKey, availableGateway, ipnsKeys }) => { +export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey, doRenameIpnsKey, doRemoveIpnsKey, availableGatewayUrl, ipnsKeys }) => { const [isGenerateKeyModalOpen, setGenerateKeyModalOpen] = useState(false) const showGenerateKeyModal = () => setGenerateKeyModalOpen(true) const hideGenerateKeyModal = () => setGenerateKeyModalOpen(false) @@ -118,7 +118,7 @@ export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey, flexShrink={1} cellRenderer={({ rowData }) => ( rowData.published - ? {rowData.id} + ? {rowData.id} : rowData.id )} /> { + // Empty value is valid: it means "use the gateway address from Kubo config". const [value, setValue] = useState(localGateway) - const [isValid, setIsValid] = useState(true) + const [isValid, setIsValid] = useState(localGateway === '' || checkValidHttpUrl(localGateway)) + const [showFailState, setShowFailState] = useState(!(localGateway === '' || checkValidHttpUrl(localGateway))) useEffect(() => { - // Empty value is valid (means "use default from Kubo config") - setIsValid(value === '' || checkValidHttpUrl(value)) + const valid = value === '' || checkValidHttpUrl(value) + setIsValid(valid) + setShowFailState(!valid) }, [value]) const onChange = (event) => setValue(event.target.value) const onSubmit = async (event) => { event.preventDefault() - if (isValid) { - doUpdateLocalGateway(value) + if (!isValid) return + // A non-empty override must be reachable from the browser, otherwise it + // would silently break download and preview links with no fallback. + if (value !== '') { + try { + await checkViaImgSrc(value) + } catch (e) { + setShowFailState(true) + return + } } + doUpdateLocalGateway(value) } const onClear = async (event) => { @@ -41,9 +53,9 @@ const LocalGatewayForm = ({ t, doUpdateLocalGateway, localGateway }) => { { {t('actions.submit')} -

- {t('localGatewayForm.description', 'Set this to your gateway URL if accessing WebUI through a reverse proxy or from a different host. Leave empty to use the gateway address from Kubo config.')} -

) } diff --git a/src/settings/SettingsPage.js b/src/settings/SettingsPage.js index ccb05230f..aa4cd01fb 100644 --- a/src/settings/SettingsPage.js +++ b/src/settings/SettingsPage.js @@ -60,7 +60,7 @@ export const SettingsPage = ({
{t('app:terms.apiAddress')} -

If your node is configured with a custom Kubo RPC API address, including a port other than the default 5001, enter it here.

+

If your node is configured with a custom Kubo RPC API address, including a port other than the default 5001, enter it here.

@@ -69,6 +69,9 @@ export const SettingsPage = ({
{t('app:terms.localGateway')} + +

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 gateway address from your Kubo config.

+
diff --git a/test/e2e/settings.test.js b/test/e2e/settings.test.js index 746ea9929..d7fe2f61a 100644 --- a/test/e2e/settings.test.js +++ b/test/e2e/settings.test.js @@ -118,6 +118,42 @@ test.describe('Settings screen', () => { await expect(publicGatewayElement).toHaveValue(DEFAULT_PATH_GATEWAY) }) + test('Submit Local Gateway and confirm it is applied', async ({ page }) => { + // custom-gw-test.localhost resolves to 127.0.0.1, so it reaches the real e2e + // kubo gateway (which serves the probe's inline-CID image). The distinctive + // hostname lets us confirm on the Status page that the override is in use. + const localGatewayUrl = `http://custom-gw-test.localhost:${process.env.IPFS_GATEWAY_PORT}` + const input = page.locator('#local-gateway') + const submitButton = page.locator('#local-gateway-submit-button') + + await expect(input).toBeVisible() + + // empty by default (falls back to the gateway from Kubo config) + await expect(input).toHaveValue('') + + await input.fill(localGatewayUrl) + + // valid URL format shows the green outline and enables submit + await expect(input).toHaveClass(/focus-outline-green/, { timeout: 5000 }) + await expect(submitButton).toBeEnabled() + + // submit runs the checkViaImgSrc probe against the real kubo gateway and saves + await submitButton.click() + + // saved: no pending changes, so submit goes back to disabled, value persists + await expect(submitButton).toBeDisabled({ timeout: 10000 }) + await expect(input).toHaveValue(localGatewayUrl) + + // confirm the override is applied where it matters: the Status page Advanced + // panel renders selectGatewayUrl, which now resolves to the override + await page.goto('/#/') + const gatewayValue = page.getByText(localGatewayUrl) + if (!(await gatewayValue.isVisible())) { + await page.getByText('Advanced').click() + } + await expect(gatewayValue).toBeVisible() + }) + test('Language selector', async ({ page }) => { const languages = await getLanguages() // Test with just a few languages to avoid timeout issues diff --git a/test/e2e/setup/global-setup.js b/test/e2e/setup/global-setup.js index 5cc047a99..0ef3fd3a9 100644 --- a/test/e2e/setup/global-setup.js +++ b/test/e2e/setup/global-setup.js @@ -59,6 +59,7 @@ const globalSetup = async config => { process.env.IPFS_RPC_ADDR = rpcAddr process.env.IPFS_RPC_ID = id process.env.IPFS_RPC_VERSION = agentVersion + process.env.IPFS_GATEWAY_PORT = String(kuboGateway.port) await ensureKuboDaemon(apiOpts)