Skip to content

Commit

Permalink
fix(files): prefer subdomain gw in copied share links (#2255)
Browse files Browse the repository at this point in the history
Co-authored-by: Marcin Rataj <[email protected]>
Co-authored-by: Russell Dempsey <[email protected]>
  • Loading branch information
3 people authored Sep 10, 2024
1 parent eefae25 commit e8c4421
Show file tree
Hide file tree
Showing 10 changed files with 362 additions and 28 deletions.
3 changes: 3 additions & 0 deletions public/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
"placeholder": "Enter a URL (http://127.0.0.1:5001) or a Multiaddr (/ip4/127.0.0.1/tcp/5001)"
},
"publicGatewayForm": {
"placeholder": "Enter a URL (https://ipfs.io)"
},
"publicSubdomainGatewayForm": {
"placeholder": "Enter a URL (https://dweb.link)"
},
"terms": {
Expand Down
3 changes: 2 additions & 1 deletion public/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"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>",
"publicGatewayDescription": "<0>Choose which <1>public gateway</1> you want to use when generating shareable links.</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>",
"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>",
"cliModal": {
"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."
Expand Down
4 changes: 3 additions & 1 deletion src/bundles/files/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ const getPinCIDs = (ipfs) => map(getRawPins(ipfs), (pin) => pin.cid)
* @property {function():string} selectApiUrl
* @property {function():string} selectGatewayUrl
* @property {function():string} selectPublicGateway
* @property {function():string} selectPublicSubdomainGateway
*
* @typedef {Object} UnkonwActions
* @property {function(string):Promise<unknown>} doUpdateHash
Expand Down Expand Up @@ -422,7 +423,8 @@ const actions = () => ({
doFilesShareLink: (files) => perform(ACTIONS.SHARE_LINK, async (ipfs, { store }) => {
// ensureMFS deliberately omitted here, see https://github.com/ipfs/ipfs-webui/issues/1744 for context.
const publicGateway = store.selectPublicGateway()
return getShareableLink(files, publicGateway, ipfs)
const publicSubdomainGateway = store.selectPublicSubdomainGateway()
return getShareableLink(files, publicGateway, publicSubdomainGateway, ipfs)
}),

/**
Expand Down
104 changes: 96 additions & 8 deletions src/bundles/gateway.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import { readSetting, writeSetting } from './local-storage.js'

export const DEFAULT_GATEWAY = 'https://ipfs.io' // TODO: switch to dweb.link when https://github.com/ipfs/kubo/issues/7318
// TODO: switch to dweb.link when https://github.com/ipfs/kubo/issues/7318
export const DEFAULT_PATH_GATEWAY = 'https://ipfs.io'
export const DEFAULT_SUBDOMAIN_GATEWAY = 'https://dweb.link'
const IMG_HASH_1PX = 'bafkreib6wedzfupqy7qh44sie42ub4mvfwnfukmw6s2564flajwnt4cvc4' // 1x1.png
const IMG_ARRAY = [
{ id: 'IMG_HASH_1PX', name: '1x1.png', hash: 'bafybeibwzifw52ttrkqlikfzext5akxu7lz4xiwjgwzmqcpdzmp3n5vnbe' },
{ id: 'IMG_HASH_1PX', name: '1x1.png', hash: IMG_HASH_1PX },
{ id: 'IMG_HASH_1PXID', name: '1x1.png', hash: 'bafkqax4jkbheodikdifaaaaabveuqrcsaaaaaaiaaaaacaidaaaaajo3k3faaaaaanieyvcfaaaabj32hxnaaaaaaf2fetstabaonwdgaaaaacsjiravicgxmnqaaaaaaiaadyrbxqzqaaaaabeuktsevzbgbaq' },
{ id: 'IMG_HASH_FAVICON', name: 'favicon.ico', hash: 'bafkreihc7efnl2prri6j6krcopelxms3xsh7undpsjqbfsasm7ikiyha4i' }
]

const readPublicGatewaySetting = () => {
const setting = readSetting('ipfsPublicGateway')
return setting || DEFAULT_GATEWAY
return setting || DEFAULT_PATH_GATEWAY
}

const readPublicSubdomainGatewaySetting = () => {
const setting = readSetting('ipfsPublicSubdomainGateway')
return setting || DEFAULT_SUBDOMAIN_GATEWAY
}

const init = () => ({
availableGateway: null,
publicGateway: readPublicGatewaySetting()
publicGateway: readPublicGatewaySetting(),
publicSubdomainGateway: readPublicSubdomainGatewaySetting()
})

export const checkValidHttpUrl = (value) => {
Expand All @@ -25,7 +34,6 @@ export const checkValidHttpUrl = (value) => {
} catch (_) {
return false
}

return url.protocol === 'http:' || url.protocol === 'https:'
}

Expand Down Expand Up @@ -58,12 +66,12 @@ const checkImgSrcPromise = (imgUrl) => {
return true
}

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

img.onerror = () => {
timeout()
reject(new Error())
reject(new Error(`Failed to load image from URL: ${imgUrl}`))
}

img.onload = () => {
Expand All @@ -76,6 +84,75 @@ const checkImgSrcPromise = (imgUrl) => {
})
}

/**
* Checks if a given URL redirects to a subdomain that starts with a specific hash.
*
* @param {URL} url - The URL to check for redirection.
* @throws {Error} Throws an error if the URL does not redirect to the expected subdomain.
* @returns {Promise<void>} A promise that resolves if the URL redirects correctly, otherwise it throws an error.
*/
async function expectSubdomainRedirect (url) {
// Detecting redirects on remote Origins is extra tricky,
// but we seem to be able to access xhr.responseURL which is enough to see
// if paths are redirected to subdomains.

const { url: responseUrl } = await fetch(url.toString())
const { hostname } = new URL(responseUrl)

if (!hostname.startsWith(IMG_HASH_1PX)) {
const msg = `Expected ${url.toString()} to redirect to subdomain '${IMG_HASH_1PX}' but instead received '${responseUrl}'`
console.error(msg)
throw new Error(msg)
}
}

/**
* Checks if an image can be loaded from a given URL within a specified timeout.
*
* @param {URL} imgUrl - The URL of the image to be loaded.
* @returns {Promise<void>} A promise that resolves if the image loads successfully within the timeout, otherwise it rejects with an error.
*/
async function checkViaImgUrl (imgUrl) {
try {
await checkImgSrcPromise(imgUrl)
} catch (error) {
throw new Error(`Error or timeout when attempting to load img from '${imgUrl.toString()}'`)
}
}

/**
* Checks if a given gateway URL is functioning correctly by verifying image loading and redirection.
*
* @param {string} gatewayUrl - The URL of the gateway to be checked.
* @returns {Promise<boolean>} A promise that resolves to true if the gateway is functioning correctly, otherwise false.
*/
export async function checkSubdomainGateway (gatewayUrl) {
if (gatewayUrl === DEFAULT_SUBDOMAIN_GATEWAY) {
// avoid sending probe requests to the default gateway every time Settings page is opened
return true
}
let imgSubdomainUrl
let imgRedirectedPathUrl
try {
const gwUrl = new URL(gatewayUrl)
imgSubdomainUrl = new URL(`${gwUrl.protocol}//${IMG_HASH_1PX}.ipfs.${gwUrl.hostname}/?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`)
imgRedirectedPathUrl = new URL(`${gwUrl.protocol}//${gwUrl.hostname}/ipfs/${IMG_HASH_1PX}?now=${Date.now()}&filename=1x1.png#x-ipfs-companion-no-redirect`)
} catch (err) {
console.error('Invalid URL:', err)
return false
}
return await checkViaImgUrl(imgSubdomainUrl)
.then(async () => expectSubdomainRedirect(imgRedirectedPathUrl))
.then(() => {
console.log(`Gateway at '${gatewayUrl}' is functioning correctly (verified image loading and redirection)`)
return true
})
.catch((err) => {
console.error(err)
return false
})
}

const bundle = {
name: 'gateway',

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

if (action.type === 'SET_PUBLIC_SUBDOMAIN_GATEWAY') {
return { ...state, publicSubdomainGateway: action.payload }
}

return state
},

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

doUpdatePublicSubdomainGateway: (address) => async ({ dispatch }) => {
await writeSetting('ipfsPublicSubdomainGateway', address)
dispatch({ type: 'SET_PUBLIC_SUBDOMAIN_GATEWAY', payload: address })
},

selectAvailableGateway: (state) => state?.gateway?.availableGateway,

selectPublicGateway: (state) => state?.gateway?.publicGateway
selectPublicGateway: (state) => state?.gateway?.publicGateway,

selectPublicSubdomainGateway: (state) => state?.gateway?.publicSubdomainGateway
}

export default bundle
10 changes: 6 additions & 4 deletions src/components/public-gateway-form/PublicGatewayForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'
import { connect } from 'redux-bundler-react'
import { withTranslation } from 'react-i18next'
import Button from '../button/Button.js'
import { checkValidHttpUrl, checkViaImgSrc, DEFAULT_GATEWAY } from '../../bundles/gateway.js'
import { checkValidHttpUrl, checkViaImgSrc, DEFAULT_PATH_GATEWAY } from '../../bundles/gateway.js'

const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {
const [value, setValue] = useState(publicGateway)
Expand Down Expand Up @@ -39,8 +39,8 @@ const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {

const onReset = async (event) => {
event.preventDefault()
setValue(DEFAULT_GATEWAY)
doUpdatePublicGateway(DEFAULT_GATEWAY)
setValue(DEFAULT_PATH_GATEWAY)
doUpdatePublicGateway(DEFAULT_PATH_GATEWAY)
}

const onKeyPress = (event) => {
Expand All @@ -63,15 +63,17 @@ const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {
/>
<div className='tr'>
<Button
id='public-path-gateway-reset-button'
minWidth={100}
height={40}
bg='bg-charcoal'
className='tc'
disabled={value === DEFAULT_GATEWAY}
disabled={value === DEFAULT_PATH_GATEWAY}
onClick={onReset}>
{t('app:actions.reset')}
</Button>
<Button
id='public-path-gateway-submit-button'
minWidth={100}
height={40}
className='mt2 mt0-l ml2-l tc'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React, { useState, useEffect } from 'react'
import { connect } from 'redux-bundler-react'
import { withTranslation } from 'react-i18next'
import Button from '../button/Button.js'
import { checkValidHttpUrl, checkSubdomainGateway, DEFAULT_SUBDOMAIN_GATEWAY } from '../../bundles/gateway.js'

const PublicSubdomainGatewayForm = ({ t, doUpdatePublicSubdomainGateway, publicSubdomainGateway }) => {
const [value, setValue] = useState(publicSubdomainGateway)
const initialIsValidGatewayUrl = !checkValidHttpUrl(value)
const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl)

// Updates the border of the input to indicate validity
useEffect(() => {
const validateUrl = async () => {
try {
const isValid = await checkSubdomainGateway(value)
setIsValidGatewayUrl(isValid)
} catch (error) {
console.error('Error checking subdomain gateway:', error)
setIsValidGatewayUrl(false)
}
}

validateUrl()
}, [value])

const onChange = (event) => setValue(event.target.value)

const onSubmit = async (event) => {
event.preventDefault()

let isValid = false
try {
isValid = await checkSubdomainGateway(value)
setIsValidGatewayUrl(true)
} catch (e) {
setIsValidGatewayUrl(false)
return
}

isValid && doUpdatePublicSubdomainGateway(value)
}

const onReset = async (event) => {
event.preventDefault()
setValue(DEFAULT_SUBDOMAIN_GATEWAY)
doUpdatePublicSubdomainGateway(DEFAULT_SUBDOMAIN_GATEWAY)
}

const onKeyPress = (event) => {
if (event.key === 'Enter') {
onSubmit(event)
}
}

return (
<form onSubmit={onSubmit}>
<input
id='public-subdomain-gateway'
aria-label={t('terms.publicSubdomainGateway')}
placeholder={t('publicSubdomainGatewayForm.placeholder')}
type='text'
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'}`}
onChange={onChange}
onKeyPress={onKeyPress}
value={value}
/>
<div className='tr'>
<Button
id='public-subdomain-gateway-reset-button'
minWidth={100}
height={40}
bg='bg-charcoal'
className='tc'
disabled={value === DEFAULT_SUBDOMAIN_GATEWAY}
onClick={onReset}>
{t('app:actions.reset')}
</Button>
<Button
id='public-subdomain-gateway-submit-button'
minWidth={100}
height={40}
className='mt2 mt0-l ml2-l tc'
disabled={!isValidGatewayUrl || value === publicSubdomainGateway}>
{t('actions.submit')}
</Button>
</div>
</form>
)
}

export default connect(
'doUpdatePublicSubdomainGateway',
'selectPublicSubdomainGateway',
withTranslation('app')(PublicSubdomainGatewayForm)
)
30 changes: 24 additions & 6 deletions src/lib/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,15 @@ export async function getDownloadLink (files, gatewayUrl, ipfs) {
}

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

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

return `${gatewayUrl}/ipfs/${cid}${filename || ''}`
const url = new URL(subdomainGatewayUrl)

/**
* dweb.link (subdomain isolation) is listed first as the new default option.
* However, ipfs.io (path gateway fallback) is also listed for CIDs that cannot be represented in a 63-character DNS label.
* This allows users to customize both the subdomain and path gateway they use, with the subdomain gateway being used by default whenever possible.
*/
let shareableLink = ''
const base32Cid = cid.toV1().toString()
if (base32Cid.length < 64) {
shareableLink = `${url.protocol}//${base32Cid}.ipfs.${url.host}${filename || ''}`
} else {
shareableLink = `${gatewayUrl}/ipfs/${cid}${filename || ''}`
}

return shareableLink
}

/**
Expand Down
Loading

0 comments on commit e8c4421

Please sign in to comment.