Skip to content

Commit e8c4421

Browse files
acul71lidelSgtPooki
authored
fix(files): prefer subdomain gw in copied share links (#2255)
Co-authored-by: Marcin Rataj <[email protected]> Co-authored-by: Russell Dempsey <[email protected]>
1 parent eefae25 commit e8c4421

File tree

10 files changed

+362
-28
lines changed

10 files changed

+362
-28
lines changed

public/locales/en/app.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@
4949
"placeholder": "Enter a URL (http://127.0.0.1:5001) or a Multiaddr (/ip4/127.0.0.1/tcp/5001)"
5050
},
5151
"publicGatewayForm": {
52+
"placeholder": "Enter a URL (https://ipfs.io)"
53+
},
54+
"publicSubdomainGatewayForm": {
5255
"placeholder": "Enter a URL (https://dweb.link)"
5356
},
5457
"terms": {

public/locales/en/settings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
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-
"publicGatewayDescription": "<0>Choose which <1>public gateway</1> you want to use when generating shareable links.</0>",
26+
"publicSubdomainGatewayDescription": "<0>Select a default <1>Subdomain Gateway</1> for generating shareable links.</0>",
27+
"publicPathGatewayDescription": "<0>Select a fallback <1>Path Gateway</1> for generating shareable links for CIDs that exceed the 63-character DNS limit.</0>",
2728
"cliDescription": "<0>Enable this option to display a \"view code\" <1></1> icon next to common IPFS commands. Clicking it opens a modal with that command's CLI code, so you can paste it into the IPFS command-line interface in your terminal.</0>",
2829
"cliModal": {
2930
"extraNotesJsonConfig": "If you've made changes to the config in this page's code editor that you'd like to save, click the download icon next to the copy button to download it as a JSON file."

src/bundles/files/actions.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ const getPinCIDs = (ipfs) => map(getRawPins(ipfs), (pin) => pin.cid)
146146
* @property {function():string} selectApiUrl
147147
* @property {function():string} selectGatewayUrl
148148
* @property {function():string} selectPublicGateway
149+
* @property {function():string} selectPublicSubdomainGateway
149150
*
150151
* @typedef {Object} UnkonwActions
151152
* @property {function(string):Promise<unknown>} doUpdateHash
@@ -422,7 +423,8 @@ const actions = () => ({
422423
doFilesShareLink: (files) => perform(ACTIONS.SHARE_LINK, async (ipfs, { store }) => {
423424
// ensureMFS deliberately omitted here, see https://github.com/ipfs/ipfs-webui/issues/1744 for context.
424425
const publicGateway = store.selectPublicGateway()
425-
return getShareableLink(files, publicGateway, ipfs)
426+
const publicSubdomainGateway = store.selectPublicSubdomainGateway()
427+
return getShareableLink(files, publicGateway, publicSubdomainGateway, ipfs)
426428
}),
427429

428430
/**

src/bundles/gateway.js

Lines changed: 96 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
11
import { readSetting, writeSetting } from './local-storage.js'
22

3-
export const DEFAULT_GATEWAY = 'https://ipfs.io' // TODO: switch to dweb.link when https://github.com/ipfs/kubo/issues/7318
3+
// TODO: switch to dweb.link when https://github.com/ipfs/kubo/issues/7318
4+
export const DEFAULT_PATH_GATEWAY = 'https://ipfs.io'
5+
export const DEFAULT_SUBDOMAIN_GATEWAY = 'https://dweb.link'
6+
const IMG_HASH_1PX = 'bafkreib6wedzfupqy7qh44sie42ub4mvfwnfukmw6s2564flajwnt4cvc4' // 1x1.png
47
const IMG_ARRAY = [
5-
{ id: 'IMG_HASH_1PX', name: '1x1.png', hash: 'bafybeibwzifw52ttrkqlikfzext5akxu7lz4xiwjgwzmqcpdzmp3n5vnbe' },
8+
{ id: 'IMG_HASH_1PX', name: '1x1.png', hash: IMG_HASH_1PX },
69
{ id: 'IMG_HASH_1PXID', name: '1x1.png', hash: 'bafkqax4jkbheodikdifaaaaabveuqrcsaaaaaaiaaaaacaidaaaaajo3k3faaaaaanieyvcfaaaabj32hxnaaaaaaf2fetstabaonwdgaaaaacsjiravicgxmnqaaaaaaiaadyrbxqzqaaaaabeuktsevzbgbaq' },
710
{ id: 'IMG_HASH_FAVICON', name: 'favicon.ico', hash: 'bafkreihc7efnl2prri6j6krcopelxms3xsh7undpsjqbfsasm7ikiyha4i' }
811
]
912

1013
const readPublicGatewaySetting = () => {
1114
const setting = readSetting('ipfsPublicGateway')
12-
return setting || DEFAULT_GATEWAY
15+
return setting || DEFAULT_PATH_GATEWAY
16+
}
17+
18+
const readPublicSubdomainGatewaySetting = () => {
19+
const setting = readSetting('ipfsPublicSubdomainGateway')
20+
return setting || DEFAULT_SUBDOMAIN_GATEWAY
1321
}
1422

1523
const init = () => ({
1624
availableGateway: null,
17-
publicGateway: readPublicGatewaySetting()
25+
publicGateway: readPublicGatewaySetting(),
26+
publicSubdomainGateway: readPublicSubdomainGatewaySetting()
1827
})
1928

2029
export const checkValidHttpUrl = (value) => {
@@ -25,7 +34,6 @@ export const checkValidHttpUrl = (value) => {
2534
} catch (_) {
2635
return false
2736
}
28-
2937
return url.protocol === 'http:' || url.protocol === 'https:'
3038
}
3139

@@ -58,12 +66,12 @@ const checkImgSrcPromise = (imgUrl) => {
5866
return true
5967
}
6068

61-
let timer = setTimeout(() => { if (timeout()) reject(new Error()) }, imgCheckTimeout)
69+
let timer = setTimeout(() => { if (timeout()) reject(new Error(`Image load timed out after ${imgCheckTimeout / 1000} seconds for URL: ${imgUrl}`)) }, imgCheckTimeout)
6270
const img = new Image()
6371

6472
img.onerror = () => {
6573
timeout()
66-
reject(new Error())
74+
reject(new Error(`Failed to load image from URL: ${imgUrl}`))
6775
}
6876

6977
img.onload = () => {
@@ -76,6 +84,75 @@ const checkImgSrcPromise = (imgUrl) => {
7684
})
7785
}
7886

87+
/**
88+
* Checks if a given URL redirects to a subdomain that starts with a specific hash.
89+
*
90+
* @param {URL} url - The URL to check for redirection.
91+
* @throws {Error} Throws an error if the URL does not redirect to the expected subdomain.
92+
* @returns {Promise<void>} A promise that resolves if the URL redirects correctly, otherwise it throws an error.
93+
*/
94+
async function expectSubdomainRedirect (url) {
95+
// Detecting redirects on remote Origins is extra tricky,
96+
// but we seem to be able to access xhr.responseURL which is enough to see
97+
// if paths are redirected to subdomains.
98+
99+
const { url: responseUrl } = await fetch(url.toString())
100+
const { hostname } = new URL(responseUrl)
101+
102+
if (!hostname.startsWith(IMG_HASH_1PX)) {
103+
const msg = `Expected ${url.toString()} to redirect to subdomain '${IMG_HASH_1PX}' but instead received '${responseUrl}'`
104+
console.error(msg)
105+
throw new Error(msg)
106+
}
107+
}
108+
109+
/**
110+
* Checks if an image can be loaded from a given URL within a specified timeout.
111+
*
112+
* @param {URL} imgUrl - The URL of the image to be loaded.
113+
* @returns {Promise<void>} A promise that resolves if the image loads successfully within the timeout, otherwise it rejects with an error.
114+
*/
115+
async function checkViaImgUrl (imgUrl) {
116+
try {
117+
await checkImgSrcPromise(imgUrl)
118+
} catch (error) {
119+
throw new Error(`Error or timeout when attempting to load img from '${imgUrl.toString()}'`)
120+
}
121+
}
122+
123+
/**
124+
* Checks if a given gateway URL is functioning correctly by verifying image loading and redirection.
125+
*
126+
* @param {string} gatewayUrl - The URL of the gateway to be checked.
127+
* @returns {Promise<boolean>} A promise that resolves to true if the gateway is functioning correctly, otherwise false.
128+
*/
129+
export async function checkSubdomainGateway (gatewayUrl) {
130+
if (gatewayUrl === DEFAULT_SUBDOMAIN_GATEWAY) {
131+
// avoid sending probe requests to the default gateway every time Settings page is opened
132+
return true
133+
}
134+
let imgSubdomainUrl
135+
let imgRedirectedPathUrl
136+
try {
137+
const gwUrl = new URL(gatewayUrl)
138+
imgSubdomainUrl = new URL(`${gwUrl.protocol}//${IMG_HASH_1PX}.ipfs.${gwUrl.hostname}/?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`)
139+
imgRedirectedPathUrl = new URL(`${gwUrl.protocol}//${gwUrl.hostname}/ipfs/${IMG_HASH_1PX}?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`)
140+
} catch (err) {
141+
console.error('Invalid URL:', err)
142+
return false
143+
}
144+
return await checkViaImgUrl(imgSubdomainUrl)
145+
.then(async () => expectSubdomainRedirect(imgRedirectedPathUrl))
146+
.then(() => {
147+
console.log(`Gateway at '${gatewayUrl}' is functioning correctly (verified image loading and redirection)`)
148+
return true
149+
})
150+
.catch((err) => {
151+
console.error(err)
152+
return false
153+
})
154+
}
155+
79156
const bundle = {
80157
name: 'gateway',
81158

@@ -88,6 +165,10 @@ const bundle = {
88165
return { ...state, publicGateway: action.payload }
89166
}
90167

168+
if (action.type === 'SET_PUBLIC_SUBDOMAIN_GATEWAY') {
169+
return { ...state, publicSubdomainGateway: action.payload }
170+
}
171+
91172
return state
92173
},
93174

@@ -98,9 +179,16 @@ const bundle = {
98179
dispatch({ type: 'SET_PUBLIC_GATEWAY', payload: address })
99180
},
100181

182+
doUpdatePublicSubdomainGateway: (address) => async ({ dispatch }) => {
183+
await writeSetting('ipfsPublicSubdomainGateway', address)
184+
dispatch({ type: 'SET_PUBLIC_SUBDOMAIN_GATEWAY', payload: address })
185+
},
186+
101187
selectAvailableGateway: (state) => state?.gateway?.availableGateway,
102188

103-
selectPublicGateway: (state) => state?.gateway?.publicGateway
189+
selectPublicGateway: (state) => state?.gateway?.publicGateway,
190+
191+
selectPublicSubdomainGateway: (state) => state?.gateway?.publicSubdomainGateway
104192
}
105193

106194
export default bundle

src/components/public-gateway-form/PublicGatewayForm.js

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
22
import { connect } from 'redux-bundler-react'
33
import { withTranslation } from 'react-i18next'
44
import Button from '../button/Button.js'
5-
import { checkValidHttpUrl, checkViaImgSrc, DEFAULT_GATEWAY } from '../../bundles/gateway.js'
5+
import { checkValidHttpUrl, checkViaImgSrc, DEFAULT_PATH_GATEWAY } from '../../bundles/gateway.js'
66

77
const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {
88
const [value, setValue] = useState(publicGateway)
@@ -39,8 +39,8 @@ const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {
3939

4040
const onReset = async (event) => {
4141
event.preventDefault()
42-
setValue(DEFAULT_GATEWAY)
43-
doUpdatePublicGateway(DEFAULT_GATEWAY)
42+
setValue(DEFAULT_PATH_GATEWAY)
43+
doUpdatePublicGateway(DEFAULT_PATH_GATEWAY)
4444
}
4545

4646
const onKeyPress = (event) => {
@@ -63,15 +63,17 @@ const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {
6363
/>
6464
<div className='tr'>
6565
<Button
66+
id='public-path-gateway-reset-button'
6667
minWidth={100}
6768
height={40}
6869
bg='bg-charcoal'
6970
className='tc'
70-
disabled={value === DEFAULT_GATEWAY}
71+
disabled={value === DEFAULT_PATH_GATEWAY}
7172
onClick={onReset}>
7273
{t('app:actions.reset')}
7374
</Button>
7475
<Button
76+
id='public-path-gateway-submit-button'
7577
minWidth={100}
7678
height={40}
7779
className='mt2 mt0-l ml2-l tc'
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React, { useState, useEffect } from 'react'
2+
import { connect } from 'redux-bundler-react'
3+
import { withTranslation } from 'react-i18next'
4+
import Button from '../button/Button.js'
5+
import { checkValidHttpUrl, checkSubdomainGateway, DEFAULT_SUBDOMAIN_GATEWAY } from '../../bundles/gateway.js'
6+
7+
const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicSubdomainGateway }) => {
8+
const [value, setValue] = useState(publicSubdomainGateway)
9+
const initialIsValidGatewayUrl = !checkValidHttpUrl(value)
10+
const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl)
11+
12+
// Updates the border of the input to indicate validity
13+
useEffect(() => {
14+
const validateUrl = async () => {
15+
try {
16+
const isValid = await checkSubdomainGateway(value)
17+
setIsValidGatewayUrl(isValid)
18+
} catch (error) {
19+
console.error('Error checking subdomain gateway:', error)
20+
setIsValidGatewayUrl(false)
21+
}
22+
}
23+
24+
validateUrl()
25+
}, [value])
26+
27+
const onChange = (event) => setValue(event.target.value)
28+
29+
const onSubmit = async (event) => {
30+
event.preventDefault()
31+
32+
let isValid = false
33+
try {
34+
isValid = await checkSubdomainGateway(value)
35+
setIsValidGatewayUrl(true)
36+
} catch (e) {
37+
setIsValidGatewayUrl(false)
38+
return
39+
}
40+
41+
isValid && doUpdatePublicSubdomainGateway(value)
42+
}
43+
44+
const onReset = async (event) => {
45+
event.preventDefault()
46+
setValue(DEFAULT_SUBDOMAIN_GATEWAY)
47+
doUpdatePublicSubdomainGateway(DEFAULT_SUBDOMAIN_GATEWAY)
48+
}
49+
50+
const onKeyPress = (event) => {
51+
if (event.key === 'Enter') {
52+
onSubmit(event)
53+
}
54+
}
55+
56+
return (
57+
<form onSubmit={onSubmit}>
58+
<input
59+
id='public-subdomain-gateway'
60+
aria-label={t('terms.publicSubdomainGateway')}
61+
placeholder={t('publicSubdomainGatewayForm.placeholder')}
62+
type='text'
63+
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'}`}
64+
onChange={onChange}
65+
onKeyPress={onKeyPress}
66+
value={value}
67+
/>
68+
<div className='tr'>
69+
<Button
70+
id='public-subdomain-gateway-reset-button'
71+
minWidth={100}
72+
height={40}
73+
bg='bg-charcoal'
74+
className='tc'
75+
disabled={value === DEFAULT_SUBDOMAIN_GATEWAY}
76+
onClick={onReset}>
77+
{t('app:actions.reset')}
78+
</Button>
79+
<Button
80+
id='public-subdomain-gateway-submit-button'
81+
minWidth={100}
82+
height={40}
83+
className='mt2 mt0-l ml2-l tc'
84+
disabled={!isValidGatewayUrl || value === publicSubdomainGateway}>
85+
{t('actions.submit')}
86+
</Button>
87+
</div>
88+
</form>
89+
)
90+
}
91+
92+
export default connect(
93+
'doUpdatePublicSubdomainGateway',
94+
'selectPublicSubdomainGateway',
95+
withTranslation('app')(PublicSubdomainGatewayForm)
96+
)

src/lib/files.js

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,15 @@ export async function getDownloadLink (files, gatewayUrl, ipfs) {
9393
}
9494

9595
/**
96-
* @param {FileStat[]} files
97-
* @param {string} gatewayUrl
98-
* @param {IPFSService} ipfs
99-
* @returns {Promise<string>}
96+
* Generates a shareable link for the provided files using a subdomain gateway as default or a path gateway as fallback.
97+
*
98+
* @param {FileStat[]} files - An array of file objects with their respective CIDs and names.
99+
* @param {string} gatewayUrl - The URL of the default IPFS gateway.
100+
* @param {string} subdomainGatewayUrl - The URL of the subdomain gateway.
101+
* @param {IPFSService} ipfs - The IPFS service instance for interacting with the IPFS network.
102+
* @returns {Promise<string>} - A promise that resolves to the shareable link for the provided files.
100103
*/
101-
export async function getShareableLink (files, gatewayUrl, ipfs) {
104+
export async function getShareableLink (files, gatewayUrl, subdomainGatewayUrl, ipfs) {
102105
let cid
103106
let filename
104107

@@ -111,7 +114,22 @@ export async function getShareableLink (files, gatewayUrl, ipfs) {
111114
cid = await makeCIDFromFiles(files, ipfs)
112115
}
113116

114-
return `${gatewayUrl}/ipfs/${cid}${filename || ''}`
117+
const url = new URL(subdomainGatewayUrl)
118+
119+
/**
120+
* dweb.link (subdomain isolation) is listed first as the new default option.
121+
* However, ipfs.io (path gateway fallback) is also listed for CIDs that cannot be represented in a 63-character DNS label.
122+
* This allows users to customize both the subdomain and path gateway they use, with the subdomain gateway being used by default whenever possible.
123+
*/
124+
let shareableLink = ''
125+
const base32Cid = cid.toV1().toString()
126+
if (base32Cid.length < 64) {
127+
shareableLink = `${url.protocol}//${base32Cid}.ipfs.${url.host}${filename || ''}`
128+
} else {
129+
shareableLink = `${gatewayUrl}/ipfs/${cid}${filename || ''}`
130+
}
131+
132+
return shareableLink
115133
}
116134

117135
/**

0 commit comments

Comments
 (0)