diff --git a/public/locales/en/app.json b/public/locales/en/app.json index ada9dbf7b..8fa6e3e82 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -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": { diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 713361a27..b76ed9a7c 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -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, including a port other than the default 5001, enter it here.", - "publicGatewayDescription": "<0>Choose which <1>public gateway you want to use when generating shareable links.", + "publicSubdomainGatewayDescription": "<0>Select a default <1>Subdomain Gateway for generating shareable links.", + "publicPathGatewayDescription": "<0>Select a fallback <1>Path Gateway for generating shareable links for CIDs that exceed the 63-character DNS limit.", "cliDescription": "<0>Enable this option to display a \"view code\" <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.", "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." diff --git a/src/bundles/files/actions.js b/src/bundles/files/actions.js index d81bed5ca..d3f349a33 100644 --- a/src/bundles/files/actions.js +++ b/src/bundles/files/actions.js @@ -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} doUpdateHash @@ -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) }), /** diff --git a/src/bundles/gateway.js b/src/bundles/gateway.js index 08e54b4bc..978437cbd 100644 --- a/src/bundles/gateway.js +++ b/src/bundles/gateway.js @@ -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) => { @@ -25,7 +34,6 @@ export const checkValidHttpUrl = (value) => { } catch (_) { return false } - return url.protocol === 'http:' || url.protocol === 'https:' } @@ -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 = () => { @@ -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} 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} 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} 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', @@ -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 }, @@ -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 diff --git a/src/components/public-gateway-form/PublicGatewayForm.js b/src/components/public-gateway-form/PublicGatewayForm.js index f4e36dcb8..b214e9f6c 100644 --- a/src/components/public-gateway-form/PublicGatewayForm.js +++ b/src/components/public-gateway-form/PublicGatewayForm.js @@ -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) @@ -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) => { @@ -63,15 +63,17 @@ const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => { />
+ +
+ + ) +} + +export default connect( + 'doUpdatePublicSubdomainGateway', + 'selectPublicSubdomainGateway', + withTranslation('app')(PublicSubdomainGatewayForm) +) diff --git a/src/lib/files.js b/src/lib/files.js index da6f5c3e1..fb1fa6302 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -93,12 +93,15 @@ export async function getDownloadLink (files, gatewayUrl, ipfs) { } /** - * @param {FileStat[]} files - * @param {string} gatewayUrl - * @param {IPFSService} ipfs - * @returns {Promise} + * 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} - 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 @@ -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 } /** diff --git a/src/lib/files.test.js b/src/lib/files.test.js index ab335092b..da6319899 100644 --- a/src/lib/files.test.js +++ b/src/lib/files.test.js @@ -1,5 +1,7 @@ /* global it, expect */ -import { normalizeFiles } from './files.js' +import { normalizeFiles, getShareableLink } from './files.js' +import { DEFAULT_SUBDOMAIN_GATEWAY, DEFAULT_PATH_GATEWAY } from '../bundles/gateway.js' +import { CID } from 'multiformats/cid' function expectRightFormat (output) { expect(Array.isArray(output)).toBe(true) @@ -249,3 +251,34 @@ it('drop multiple directories', async () => { expectRightFormat(output) expectRightOutput(output, expected) }) + +it('should get a subdomain gateway url', async () => { + const ipfs = {} + const myCID = CID.parse('QmZTR5bcpQD7cFgTorqxZDYaew1Wqgfbd2ud9QqGPAkK2V') + const file = { + cid: myCID, + name: 'example.txt' + } + const files = [file] + + const url = new URL(DEFAULT_SUBDOMAIN_GATEWAY) + const shareableLink = await getShareableLink(files, DEFAULT_PATH_GATEWAY, DEFAULT_SUBDOMAIN_GATEWAY, ipfs) + const base32Cid = 'bafybeifffq3aeaymxejo37sn5fyaf7nn7hkfmzwdxyjculx3lw4tyhk7uy' + const rightShareableLink = `${url.protocol}//${base32Cid}.ipfs.${url.host}` + expect(shareableLink).toBe(rightShareableLink) +}) + +it('should get a path gateway url', async () => { + const ipfs = {} + // very long CID v1 (using sha3-512) + const veryLongCidv1 = 'bagaaifcavabu6fzheerrmtxbbwv7jjhc3kaldmm7lbnvfopyrthcvod4m6ygpj3unrcggkzhvcwv5wnhc5ufkgzlsji7agnmofovc2g4a3ui7ja' + const myCID = CID.parse(veryLongCidv1) + const file = { + cid: myCID, + name: 'example.txt' + } + const files = [file] + + const res = await getShareableLink(files, DEFAULT_PATH_GATEWAY, DEFAULT_SUBDOMAIN_GATEWAY, ipfs) + expect(res).toBe(DEFAULT_PATH_GATEWAY + '/ipfs/' + veryLongCidv1) +}) diff --git a/src/settings/SettingsPage.js b/src/settings/SettingsPage.js index 6e466b96a..54fa5101f 100644 --- a/src/settings/SettingsPage.js +++ b/src/settings/SettingsPage.js @@ -17,6 +17,7 @@ import IpnsManager from '../components/ipns-manager/IpnsManager.js' import AnalyticsToggle from '../components/analytics-toggle/AnalyticsToggle.js' import ApiAddressForm from '../components/api-address-form/ApiAddressForm.js' import PublicGatewayForm from '../components/public-gateway-form/PublicGatewayForm.js' +import PublicSubdomainGatewayForm from '../components/public-subdomain-gateway-form/PublicSubdomainGatewayForm.js' import { JsonEditor } from './editor/JsonEditor.js' import Experiments from '../components/experiments/ExperimentsPanel.js' import Title from './Title.js' @@ -66,12 +67,18 @@ export const SettingsPage = ({
{t('app:terms.publicGateway')} - -

Choose which public gateway you want to use to open your files.

-
- -
-
+ +

Select a default Subdomain Gateway for generating shareable links.

+
+ + +
+ +

Select a fallback Path Gateway for generating shareable links for CIDs that exceed the 63-character DNS limit.

+
+ +
+ {t('ipnsPublishingKeys.title')} diff --git a/test/e2e/settings.test.js b/test/e2e/settings.test.js index 59dc2b123..99bbad38b 100644 --- a/test/e2e/settings.test.js +++ b/test/e2e/settings.test.js @@ -1,6 +1,6 @@ import { readFile } from 'node:fs/promises' - import { test, expect } from './setup/coverage.js' +import { DEFAULT_PATH_GATEWAY, DEFAULT_SUBDOMAIN_GATEWAY } from '../../src/bundles/gateway.js' const languageFilePromise = readFile('./src/lib/languages.json', 'utf8') @@ -11,6 +11,55 @@ const getLanguages = async () => { return languages } +/** + * Function to check if an element contains a specific class within a maximum wait time. + * @param {Page} page - The page object. + * @param {ElementHandle} element - The element to check. + * @param {string} className - The class name to check for. + * @param {number} maxWaitTime - Maximum wait time in milliseconds. + * @param {number} pollInterval - Interval between polls in milliseconds. + * @returns {Promise} - A promise that resolves to a boolean indicating if the class was found. + */ +async function checkClassWithTimeout (page, element, className, maxWaitTime = 16000, pollInterval = 500) { + const startTime = Date.now() + while ((Date.now() - startTime) < maxWaitTime) { + const hasClass = await element.evaluate((el, className) => el.classList.contains(className), className) + if (hasClass) return true + await page.waitForTimeout(pollInterval) + } + return false +} + +/** + * Function to submit a gateway and check for success/failure. + * @param {Page} page - The page object. + * @param {ElementHandle} inputElement - The input element to fill. + * @param {ElementHandle|null} submitButton - The submit button element to click, or null if no button is available. + * @param {string} gatewayURL - The gateway URL to fill. + * @param {string} expectedClass - The expected class after submission. + */ +async function submitGatewayAndCheck (page, inputElement, submitButton, gatewayURL, expectedClass) { + await inputElement.fill(gatewayURL) + // Check if the submit button is not null, and click it only if it's available + if (submitButton) { + await submitButton.click() + } + const hasExpectedClass = await checkClassWithTimeout(page, inputElement, expectedClass) + expect(hasExpectedClass).toBe(true) +} + +/** + * Function to reset a gateway and verify the reset. + * @param {ElementHandle} resetButton - The reset button element to click. + * @param {ElementHandle} inputElement - The input element to check. + * @param {string} expectedValue - The expected value after reset. + */ +async function resetGatewayAndCheck (resetButton, inputElement, expectedValue) { + await resetButton.click() + const gatewayText = await inputElement.evaluate(element => element.value) + expect(gatewayText).toContain(expectedValue) +} + test.describe('Settings screen', () => { test.beforeEach(async ({ page }) => { await page.goto('/#/settings') @@ -25,6 +74,41 @@ test.describe('Settings screen', () => { await page.waitForSelector(`text=${id}`) }) + test('Submit/Reset Public Subdomain Gateway', async ({ page }) => { + // Wait for the necessary elements to be available in the DOM + const publicSubdomainGatewayElement = await page.waitForSelector('#public-subdomain-gateway') + const publicSubdomainGatewaySubmitButton = await page.waitForSelector('#public-subdomain-gateway-submit-button') + const publicSubdomainGatewayResetButton = await page.waitForSelector('#public-subdomain-gateway-reset-button') + + // Check that submitting a wrong Subdomain Gateway triggers a red outline + await submitGatewayAndCheck(page, publicSubdomainGatewayElement, null, DEFAULT_PATH_GATEWAY, 'focus-outline-red') + + // Check that submitting a correct Subdomain Gateway triggers a green outline + await submitGatewayAndCheck(page, publicSubdomainGatewayElement, publicSubdomainGatewaySubmitButton, DEFAULT_SUBDOMAIN_GATEWAY + '/', 'focus-outline-green') + + // Check the Reset button functionality + await resetGatewayAndCheck(publicSubdomainGatewayResetButton, publicSubdomainGatewayElement, DEFAULT_SUBDOMAIN_GATEWAY) + }) + + test('Submit/Reset Public Path Gateway', async ({ page }) => { + // Custom timeout for this specific test + test.setTimeout(32000) + + // Wait for the necessary elements to be available in the DOM + const publicGatewayElement = await page.waitForSelector('#public-gateway') + const publicGatewaySubmitButton = await page.waitForSelector('#public-path-gateway-submit-button') + const publicGatewayResetButton = await page.waitForSelector('#public-path-gateway-reset-button') + + // Check that submitting a wrong Path Gateway triggers a red outline + await submitGatewayAndCheck(page, publicGatewayElement, publicGatewaySubmitButton, DEFAULT_PATH_GATEWAY + '1999', 'focus-outline-red') + + // Check that submitting a correct Path Gateway triggers a green outline + await submitGatewayAndCheck(page, publicGatewayElement, publicGatewaySubmitButton, DEFAULT_SUBDOMAIN_GATEWAY, 'focus-outline-green') + + // Check the Reset button functionality + await resetGatewayAndCheck(publicGatewayResetButton, publicGatewayElement, DEFAULT_PATH_GATEWAY) + }) + test('Language selector', async ({ page }) => { const languages = await getLanguages() for (const lang of Object.values(languages).map((lang) => lang.locale)) {