From fa7960edb21ba55dd8e581974fbe1b38027c58d7 Mon Sep 17 00:00:00 2001 From: Nilton Volpato Date: Wed, 1 Apr 2026 07:24:30 -0700 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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)"