Skip to content

Commit 6a4acab

Browse files
NiltonVolpatolidel
andauthored
feat(config): add Local Gateway URL for reverse proxy and Docker (#2486)
* 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 * 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. * 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. * 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. * 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 --------- Co-authored-by: Marcin Rataj <lidel@lidel.org>
1 parent 2007bd8 commit 6a4acab

12 files changed

Lines changed: 357 additions & 23 deletions

File tree

public/locales/en/app.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@
4848
"publicGatewayForm": {
4949
"placeholder": "Enter a URL (https://ipfs.io)"
5050
},
51+
"localGatewayForm": {
52+
"placeholder": "Enter a URL (http://localhost:8080)"
53+
},
5154
"publicSubdomainGatewayForm": {
5255
"placeholder": "Enter a URL (https://dweb.link)"
5356
},
@@ -87,6 +90,7 @@
8790
"pinStatus": "Pin Status",
8891
"publicKey": "Public key",
8992
"publicGateway": "Public Gateway",
93+
"localGateway": "Local Gateway",
9094
"rateIn": "Rate in",
9195
"rateOut": "Rate out",
9296
"repo": "Repo",

public/locales/en/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"translationProjectLink": "Join the IPFS Translation Project"
2424
},
2525
"apiDescription": "<0>If your node is configured with a <1>custom Kubo RPC API address</1>, including a port other than the default 5001, enter it here.</0>",
26+
"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</1> from your Kubo config.</0>",
2627
"publicSubdomainGatewayDescription": "<0>Select a default <1>Subdomain Gateway</1> for generating shareable links.</0>",
2728
"publicPathGatewayDescription": "<0>Select a fallback <1>Path Gateway</1> for generating shareable links for CIDs that exceed the 63-character DNS limit.</0>",
2829
"retrievalDiagnosticService": {

src/bundles/config.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ const bundle = createAsyncResourceBundle({
2323

2424
const config = JSON.parse(conf)
2525

26+
// An explicit Local Gateway URL is the gateway the browser is meant to reach
27+
// for everything (reverse proxy, Docker, non-default host or port), so trust
28+
// it for the reachability-probed availableGateway too. Without this, the
29+
// 127.0.0.1 probe below fails for remote users and previews, thumbnails and
30+
// IPNS links fall back to the public gateway instead of the user's own node.
31+
// https://github.com/ipfs/ipfs-webui/issues/2458
32+
const localGateway = store.selectLocalGateway()
33+
if (localGateway) {
34+
store.doSetAvailableGateway(localGateway)
35+
return conf
36+
}
37+
2638
const publicGateway = store.selectPublicGateway()
2739
const url = getURLFromAddress('Gateway', config) || publicGateway
2840

@@ -66,7 +78,13 @@ bundle.reactIsSameOriginToBridge = createSelector(
6678
bundle.selectGatewayUrl = createSelector(
6779
'selectConfigObject',
6880
'selectPublicGateway',
69-
(config, publicGateway) => getURLFromAddress('Gateway', config) || publicGateway
81+
'selectLocalGateway',
82+
(config, publicGateway, localGateway) => {
83+
// Priority: 1) User-configured local gateway, 2) Kubo config, 3) Public gateway
84+
const url = localGateway || getURLFromAddress('Gateway', config) || publicGateway
85+
// Normalize: remove trailing slashes to avoid double slashes when constructing paths
86+
return url.replace(/\/+$/, '')
87+
}
7088
)
7189

7290
bundle.selectAvailableGatewayUrl = createSelector(

src/bundles/config.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/* global describe, it, expect, beforeEach, afterEach */
2+
import { jest } from '@jest/globals'
3+
import { composeBundles } from 'redux-bundler'
4+
import configBundle from './config.js'
5+
import gatewayBundle from './gateway.js'
6+
7+
// selectIpfsReady is false so the reactConfigFetch reactor stays quiet and we
8+
// drive doFetchConfig (which runs getPromise) deterministically from the test.
9+
const createMockIpfsBundle = (config) => ({
10+
name: 'ipfs',
11+
getExtraArgs: () => ({ getIpfs: () => ({ config: { getAll: async () => config } }) }),
12+
selectIpfsReady: () => false,
13+
selectIpfsConnected: () => false
14+
})
15+
16+
const createStore = (config) => composeBundles(
17+
createMockIpfsBundle(config),
18+
gatewayBundle,
19+
configBundle
20+
)()
21+
22+
describe('gateway selection with a Local Gateway URL override', () => {
23+
// getURLFromAddress logs when @multiformats/multiaddr-to-uri cannot resolve a
24+
// multiaddr, which it cannot under jest; the override path does not need it.
25+
let logSpy
26+
beforeEach(() => {
27+
window.localStorage.clear()
28+
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {})
29+
})
30+
afterEach(() => logSpy.mockRestore())
31+
32+
// https://github.com/ipfs/ipfs-webui/issues/2458
33+
it('routes the configured and available gateway through the override, and falls back to the Kubo config gateway when cleared', async () => {
34+
const store = createStore({ Addresses: { Gateway: '/ip4/127.0.0.1/tcp/8080' } })
35+
36+
// trailing slash is normalized away on save
37+
await store.doUpdateLocalGateway('https://ipfs.example.com/')
38+
await store.doFetchConfig()
39+
40+
expect(store.selectLocalGateway()).toBe('https://ipfs.example.com')
41+
// override beats the Kubo config gateway for download links
42+
expect(store.selectGatewayUrl()).toBe('https://ipfs.example.com')
43+
// getPromise sets availableGateway to the override, so previews, thumbnails
44+
// and IPNS links (which use selectAvailableGateway*) honor it too
45+
expect(store.selectAvailableGateway()).toBe('https://ipfs.example.com')
46+
expect(store.selectAvailableGatewayUrl()).toBe('https://ipfs.example.com')
47+
48+
// clearing the override removes it from gateway selection (the multiaddr
49+
// ->URI fallback to the Kubo config gateway is covered by e2e, since
50+
// @multiformats/multiaddr-to-uri does not load under jest)
51+
await store.doUpdateLocalGateway('')
52+
expect(store.selectLocalGateway()).toBe('')
53+
})
54+
})

src/bundles/gateway.js

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ const readPublicGatewaySetting = () => {
1919
return setting || DEFAULT_PATH_GATEWAY
2020
}
2121

22+
const readLocalGatewaySetting = () => {
23+
const setting = readSetting('ipfsLocalGateway')
24+
// Return empty string if not set, so we can distinguish between
25+
// "not configured" and "configured to empty"
26+
return setting || ''
27+
}
28+
2229
const readPublicSubdomainGatewaySetting = () => {
2330
const setting = readSetting('ipfsPublicSubdomainGateway')
2431
return setting || DEFAULT_SUBDOMAIN_GATEWAY
@@ -33,7 +40,8 @@ const init = () => ({
3340
availableGateway: null,
3441
publicGateway: readPublicGatewaySetting(),
3542
publicSubdomainGateway: readPublicSubdomainGatewaySetting(),
36-
ipfsCheckUrl: readIpfsCheckUrlSetting()
43+
ipfsCheckUrl: readIpfsCheckUrlSetting(),
44+
localGateway: readLocalGatewaySetting()
3745
})
3846

3947
/**
@@ -51,6 +59,31 @@ export const checkValidHttpUrl = (value) => {
5159
return url.protocol === 'http:' || url.protocol === 'https:'
5260
}
5361

62+
/**
63+
* Default `kuboGateway` config consumed by Helia/verified-fetch
64+
* (ipld-explorer-components) on the Explore page when no explicit Local Gateway
65+
* URL is set.
66+
*/
67+
export const DEFAULT_KUBO_GATEWAY = { trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: false } } }
68+
69+
/**
70+
* Convert a Local Gateway URL into the `kuboGateway` config shape consumed by
71+
* Helia/verified-fetch on the Explore page, so the explorer fetches blocks from
72+
* the same gateway the rest of the WebUI uses.
73+
* @param {string} gatewayUrl
74+
* @returns {{host: string, port: string, protocol: string, trustlessBlockBrokerConfig: object}}
75+
*/
76+
export const localGatewayToKuboGateway = (gatewayUrl) => {
77+
const url = new URL(gatewayUrl)
78+
const protocol = url.protocol.replace(':', '')
79+
return {
80+
host: url.hostname,
81+
port: url.port || (url.protocol === 'https:' ? '443' : '80'),
82+
protocol,
83+
trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: protocol === 'http' } }
84+
}
85+
}
86+
5487
/**
5588
* Check if any hashes from IMG_ARRAY can be loaded from the provided gatewayUrl
5689
* @param {string} gatewayUrl - The gateway URL to check
@@ -71,7 +104,9 @@ export const checkViaImgSrc = (gatewayUrl) => {
71104
*/
72105
// @ts-expect-error - Promise.any requires ES2021 but we're on ES2020
73106
return Promise.any(IMG_ARRAY.map(element => {
74-
const imgUrl = new URL(`${url.protocol}//${url.hostname}/ipfs/${element.hash}?now=${Date.now()}&filename=${element.name}#x-ipfs-companion-no-redirect`)
107+
// url.host (not hostname) keeps the port, so the probe also works for local
108+
// gateways on non-default ports, e.g. http://127.0.0.1:8080.
109+
const imgUrl = new URL(`${url.protocol}//${url.host}/ipfs/${element.hash}?now=${Date.now()}&filename=${element.name}#x-ipfs-companion-no-redirect`)
75110
return checkImgSrcPromise(imgUrl)
76111
}))
77112
}
@@ -207,6 +242,10 @@ const bundle = {
207242
return { ...state, ipfsCheckUrl: action.payload }
208243
}
209244

245+
if (action.type === 'SET_LOCAL_GATEWAY') {
246+
return { ...state, localGateway: action.payload }
247+
}
248+
210249
return state
211250
},
212251

@@ -243,6 +282,29 @@ const bundle = {
243282
dispatch({ type: 'SET_IPFS_CHECK_URL', payload: url })
244283
},
245284

285+
/**
286+
* @param {string} address
287+
* @returns {function({dispatch: Function}): Promise<void>}
288+
*/
289+
doUpdateLocalGateway: (address) => async ({ dispatch }) => {
290+
// Normalize: remove trailing slashes
291+
const normalizedAddress = address.replace(/\/+$/, '')
292+
await writeSetting('ipfsLocalGateway', normalizedAddress)
293+
dispatch({ type: 'SET_LOCAL_GATEWAY', payload: normalizedAddress })
294+
295+
// Keep kuboGateway (used by Helia/Explore) in sync with the override.
296+
if (normalizedAddress) {
297+
try {
298+
await writeSetting('kuboGateway', localGatewayToKuboGateway(normalizedAddress))
299+
} catch (e) {
300+
console.error('Error syncing ipfsLocalGateway to kuboGateway:', e)
301+
}
302+
} else {
303+
// Override cleared: restore defaults so Explore stops using the old host.
304+
await writeSetting('kuboGateway', DEFAULT_KUBO_GATEWAY)
305+
}
306+
},
307+
246308
/**
247309
* @param {any} state
248310
* @returns {string|null}
@@ -265,7 +327,13 @@ const bundle = {
265327
* @param {any} state
266328
* @returns {string}
267329
*/
268-
selectIpfsCheckUrl: (state) => state?.gateway?.ipfsCheckUrl
330+
selectIpfsCheckUrl: (state) => state?.gateway?.ipfsCheckUrl,
331+
332+
/**
333+
* @param {any} state
334+
* @returns {string}
335+
*/
336+
selectLocalGateway: (state) => state?.gateway?.localGateway
269337
}
270338

271339
export default bundle

src/bundles/gateway.test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* global describe, it, expect */
2+
import { localGatewayToKuboGateway, checkValidHttpUrl } from './gateway.js'
3+
4+
describe('localGatewayToKuboGateway', () => {
5+
it('keeps an explicit port and treats http as insecure', () => {
6+
expect(localGatewayToKuboGateway('http://127.0.0.1:8080')).toEqual({
7+
host: '127.0.0.1',
8+
port: '8080',
9+
protocol: 'http',
10+
trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: true } }
11+
})
12+
})
13+
14+
it('defaults the port to 443 for https and keeps it secure', () => {
15+
expect(localGatewayToKuboGateway('https://ipfs.example.com')).toEqual({
16+
host: 'ipfs.example.com',
17+
port: '443',
18+
protocol: 'https',
19+
trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: false } }
20+
})
21+
})
22+
23+
it('defaults the port to 80 for http without an explicit port', () => {
24+
expect(localGatewayToKuboGateway('http://gateway.local').port).toBe('80')
25+
})
26+
27+
it('throws on an invalid URL', () => {
28+
expect(() => localGatewayToKuboGateway('not a url')).toThrow()
29+
})
30+
})
31+
32+
describe('checkValidHttpUrl', () => {
33+
it('accepts http and https URLs, including non-default ports', () => {
34+
expect(checkValidHttpUrl('http://127.0.0.1:8080')).toBe(true)
35+
expect(checkValidHttpUrl('https://ipfs.example.com')).toBe(true)
36+
})
37+
38+
it('rejects non-http(s) and malformed values', () => {
39+
expect(checkValidHttpUrl('ftp://example.com')).toBe(false)
40+
expect(checkValidHttpUrl('not a url')).toBe(false)
41+
expect(checkValidHttpUrl('')).toBe(false)
42+
})
43+
})

src/bundles/ipfs-provider.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import last from 'it-last'
66
import * as Enum from '../lib/enum.js'
77
import { perform } from './task.js'
88
import { readSetting, writeSetting } from './local-storage.js'
9+
import { localGatewayToKuboGateway, DEFAULT_KUBO_GATEWAY } from './gateway.js'
910
import { contextBridge } from '../helpers/context-bridge'
1011
import { createSelector } from 'redux-bundler'
1112

@@ -332,12 +333,21 @@ const actions = {
332333
}
333334

334335
const kuboGateway = readSetting('kuboGateway')
335-
if (kuboGateway === null || typeof kuboGateway === 'string' || typeof kuboGateway === 'boolean' || typeof kuboGateway === 'number') {
336+
const localGateway = readSetting('ipfsLocalGateway')
337+
338+
if (typeof localGateway === 'string' && localGateway) {
339+
// User has configured a custom local gateway, sync it to kuboGateway for Helia/Explore
340+
try {
341+
await writeSetting('kuboGateway', localGatewayToKuboGateway(localGateway))
342+
} catch (e) {
343+
console.error('Error parsing ipfsLocalGateway for kuboGateway:', e)
344+
}
345+
} else if (kuboGateway === null || typeof kuboGateway === 'string' || typeof kuboGateway === 'boolean' || typeof kuboGateway === 'number') {
336346
// empty or invalid, set defaults
337-
await writeSetting('kuboGateway', { trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: false } } })
347+
await writeSetting('kuboGateway', DEFAULT_KUBO_GATEWAY)
338348
} else if (/** @type {Record<string, any>} */(kuboGateway).trustlessBlockBrokerConfig == null) {
339349
// missing trustlessBlockBrokerConfig, set defaults
340-
await writeSetting('kuboGateway', { ...kuboGateway, trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: false } } })
350+
await writeSetting('kuboGateway', { ...kuboGateway, ...DEFAULT_KUBO_GATEWAY })
341351
}
342352
},
343353

src/components/ipns-manager/IpnsManager.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ const OptionsCell = ({ t, name, showRenameKeyModal, showRemoveKeyModal }) => {
5353
)
5454
}
5555

56-
export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey, doRenameIpnsKey, doRemoveIpnsKey, availableGateway, ipnsKeys }) => {
56+
export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey, doRenameIpnsKey, doRemoveIpnsKey, availableGatewayUrl, ipnsKeys }) => {
5757
const [isGenerateKeyModalOpen, setGenerateKeyModalOpen] = useState(false)
5858
const showGenerateKeyModal = () => setGenerateKeyModalOpen(true)
5959
const hideGenerateKeyModal = () => setGenerateKeyModalOpen(false)
@@ -118,7 +118,7 @@ export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey,
118118
flexShrink={1}
119119
cellRenderer={({ rowData }) => (
120120
rowData.published
121-
? <a href={`${availableGateway}/ipns/${rowData.id}`} target='_blank' rel='noopener noreferrer' className='link blue'>{rowData.id}</a>
121+
? <a href={`${availableGatewayUrl}/ipns/${rowData.id}`} target='_blank' rel='noopener noreferrer' className='link blue'>{rowData.id}</a>
122122
: rowData.id
123123
)} />
124124
<Column
@@ -184,7 +184,7 @@ IpnsManager.defaultProps = {
184184
export default connect(
185185
'selectIpfsReady',
186186
'selectIpnsKeys',
187-
'selectAvailableGateway',
187+
'selectAvailableGatewayUrl',
188188
'doFetchIpnsKeys',
189189
'doGenerateIpnsKey',
190190
'doRemoveIpnsKey',

0 commit comments

Comments
 (0)