Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions public/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
"publicGatewayForm": {
"placeholder": "Enter a URL (https://ipfs.io)"
},
"localGatewayForm": {
"placeholder": "Enter a URL (http://localhost:8080)"
},
"publicSubdomainGatewayForm": {
"placeholder": "Enter a URL (https://dweb.link)"
},
Expand Down Expand Up @@ -87,6 +90,7 @@
"pinStatus": "Pin Status",
"publicKey": "Public key",
"publicGateway": "Public Gateway",
"localGateway": "Local Gateway",
"rateIn": "Rate in",
"rateOut": "Rate out",
"repo": "Repo",
Expand Down
1 change: 1 addition & 0 deletions public/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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</1>, including a port other than the default 5001, enter it here.</0>",
"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>",
"publicSubdomainGatewayDescription": "<0>Select a default <1>Subdomain Gateway</1> for generating shareable links.</0>",
"publicPathGatewayDescription": "<0>Select a fallback <1>Path Gateway</1> for generating shareable links for CIDs that exceed the 63-character DNS limit.</0>",
"retrievalDiagnosticService": {
Expand Down
20 changes: 19 additions & 1 deletion src/bundles/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -66,7 +78,13 @@ 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
const url = localGateway || getURLFromAddress('Gateway', config) || publicGateway
// Normalize: remove trailing slashes to avoid double slashes when constructing paths
return url.replace(/\/+$/, '')
}
)

bundle.selectAvailableGatewayUrl = createSelector(
Expand Down
54 changes: 54 additions & 0 deletions src/bundles/config.test.js
Original file line number Diff line number Diff line change
@@ -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('')
})
})
74 changes: 71 additions & 3 deletions src/bundles/gateway.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,7 +40,8 @@ const init = () => ({
availableGateway: null,
publicGateway: readPublicGatewaySetting(),
publicSubdomainGateway: readPublicSubdomainGatewaySetting(),
ipfsCheckUrl: readIpfsCheckUrlSetting()
ipfsCheckUrl: readIpfsCheckUrlSetting(),
localGateway: readLocalGatewaySetting()
})

/**
Expand All @@ -51,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
Expand All @@ -71,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)
}))
}
Expand Down Expand Up @@ -207,6 +242,10 @@ const bundle = {
return { ...state, ipfsCheckUrl: action.payload }
}

if (action.type === 'SET_LOCAL_GATEWAY') {
return { ...state, localGateway: action.payload }
}

return state
},

Expand Down Expand Up @@ -243,6 +282,29 @@ const bundle = {
dispatch({ type: 'SET_IPFS_CHECK_URL', payload: url })
},

/**
* @param {string} address
* @returns {function({dispatch: Function}): Promise<void>}
*/
doUpdateLocalGateway: (address) => async ({ dispatch }) => {
// Normalize: remove trailing slashes
const normalizedAddress = address.replace(/\/+$/, '')
await writeSetting('ipfsLocalGateway', normalizedAddress)
dispatch({ type: 'SET_LOCAL_GATEWAY', payload: normalizedAddress })

// Keep kuboGateway (used by Helia/Explore) in sync with the override.
if (normalizedAddress) {
try {
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)
}
},

/**
* @param {any} state
* @returns {string|null}
Expand All @@ -265,7 +327,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
43 changes: 43 additions & 0 deletions src/bundles/gateway.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
16 changes: 13 additions & 3 deletions src/bundles/ipfs-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -332,12 +333,21 @@ const actions = {
}

const kuboGateway = readSetting('kuboGateway')
if (kuboGateway === null || typeof kuboGateway === 'string' || typeof kuboGateway === 'boolean' || typeof kuboGateway === 'number') {
const localGateway = readSetting('ipfsLocalGateway')

if (typeof localGateway === 'string' && localGateway) {
// User has configured a custom local gateway, sync it to kuboGateway for Helia/Explore
try {
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<string, any>} */(kuboGateway).trustlessBlockBrokerConfig == null) {
// missing trustlessBlockBrokerConfig, set defaults
await writeSetting('kuboGateway', { ...kuboGateway, trustlessBlockBrokerConfig: { init: { allowLocal: true, allowInsecure: false } } })
await writeSetting('kuboGateway', { ...kuboGateway, ...DEFAULT_KUBO_GATEWAY })
}
},

Expand Down
6 changes: 3 additions & 3 deletions src/components/ipns-manager/IpnsManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -118,7 +118,7 @@ export const IpnsManager = ({ t, ipfsReady, doFetchIpnsKeys, doGenerateIpnsKey,
flexShrink={1}
cellRenderer={({ rowData }) => (
rowData.published
? <a href={`${availableGateway}/ipns/${rowData.id}`} target='_blank' rel='noopener noreferrer' className='link blue'>{rowData.id}</a>
? <a href={`${availableGatewayUrl}/ipns/${rowData.id}`} target='_blank' rel='noopener noreferrer' className='link blue'>{rowData.id}</a>
: rowData.id
)} />
<Column
Expand Down Expand Up @@ -184,7 +184,7 @@ IpnsManager.defaultProps = {
export default connect(
'selectIpfsReady',
'selectIpnsKeys',
'selectAvailableGateway',
'selectAvailableGatewayUrl',
'doFetchIpnsKeys',
'doGenerateIpnsKey',
'doRemoveIpnsKey',
Expand Down
Loading
Loading