Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(files): prefer subdomain gw in copied share links #2255

Merged
merged 28 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cdde888
Fix issue #2244: Updated public gateway settings, added subdomain gat…
acul71 Sep 3, 2024
b3cbe79
Merge pull request #1 from ipfs/main
acul71 Sep 3, 2024
3373d44
Update src/bundles/gateway.js
acul71 Sep 4, 2024
c3de5c3
Update src/bundles/gateway.js
acul71 Sep 4, 2024
b0d128f
Update src/settings/SettingsPage.js
acul71 Sep 4, 2024
7a64e72
Update src/settings/SettingsPage.js
acul71 Sep 4, 2024
fda5381
Incorporated review suggestions for issue #2244: Updated constants, r…
acul71 Sep 4, 2024
64e95f6
Merge branch 'main' into fix-issue-2244
SgtPooki Sep 4, 2024
b013645
Update src/bundles/gateway.js
acul71 Sep 4, 2024
08d2073
Update src/bundles/gateway.js
acul71 Sep 4, 2024
39840d0
Update src/bundles/gateway.js
acul71 Sep 4, 2024
318285e
Update src/bundles/gateway.js
acul71 Sep 4, 2024
ef940f6
Update src/lib/files.js
acul71 Sep 4, 2024
8cdafe6
Update src/lib/files.test.js
acul71 Sep 4, 2024
e22ddb0
Update src/lib/files.test.js
acul71 Sep 4, 2024
61f8e20
Update src/settings/SettingsPage.js
acul71 Sep 4, 2024
a31026c
Update src/lib/files.test.js
acul71 Sep 4, 2024
0d8fc4b
Update src/components/public-subdomain-gateway-form/PublicSubdomainGa…
acul71 Sep 4, 2024
5cab67d
Update src/components/public-subdomain-gateway-form/PublicSubdomainGa…
acul71 Sep 4, 2024
c3c96e2
Update src/components/public-subdomain-gateway-form/PublicSubdomainGa…
acul71 Sep 4, 2024
05e4841
Update src/components/public-subdomain-gateway-form/PublicSubdomainGa…
acul71 Sep 4, 2024
5c9ec59
Update src/components/public-subdomain-gateway-form/PublicSubdomainGa…
acul71 Sep 4, 2024
9b512a4
nit: moved kind helper functions outside the test blocks
acul71 Sep 4, 2024
7c96305
Fix submit subdomain gateway check validity test
acul71 Sep 4, 2024
ee529a9
Update src/bundles/gateway.js
acul71 Sep 5, 2024
d8e81bc
Update src/lib/files.test.js
acul71 Sep 5, 2024
2de3310
fix: settings.test.js workaround for no button
acul71 Sep 5, 2024
44a43be
fix(settings): avoid probing dweb.link on load
lidel Sep 10, 2024
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
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
Loading