diff --git a/public/locales/en/app.json b/public/locales/en/app.json index c40e0a833..3704b0a8c 100644 --- a/public/locales/en/app.json +++ b/public/locales/en/app.json @@ -54,6 +54,10 @@ "publicSubdomainGatewayForm": { "placeholder": "Enter a URL (https://dweb.link)" }, + "ipfsCheckForm": { + "label": "Retrieval Check Service URL", + "placeholder": "Enter a URL (https://check.ipfs.network)" + }, "terms": { "address": "Address", "addresses": "Addresses", diff --git a/public/locales/en/diagnostics.json b/public/locales/en/diagnostics.json index db53eecc1..8af190760 100644 --- a/public/locales/en/diagnostics.json +++ b/public/locales/en/diagnostics.json @@ -3,6 +3,7 @@ "description": "View detailed diagnostic information about your IPFS node and system configuration.", "tabs": { "logs": "Logs", + "check": "Retrieval Check", "connectivity": "Connectivity" }, "logs": { diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index b76ed9a7c..49319c69f 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -25,6 +25,10 @@ "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.", "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.", + "retrievalDiagnosticService": { + "title": "Retrieval Diagnostic Service", + "description": "Configure the URL of the <0>ipfs-check service used for <1>retrieval diagnostics. This service checks if content can be successfully fetched from your node and other nodes hosting a specific CID, helping you troubleshoot sharing issues." + }, "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/gateway.js b/src/bundles/gateway.js index 978437cbd..2e5188460 100644 --- a/src/bundles/gateway.js +++ b/src/bundles/gateway.js @@ -3,6 +3,7 @@ import { readSetting, writeSetting } from './local-storage.js' // 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' +export const DEFAULT_IPFS_CHECK_URL = 'https://check.ipfs.network' const IMG_HASH_1PX = 'bafkreib6wedzfupqy7qh44sie42ub4mvfwnfukmw6s2564flajwnt4cvc4' // 1x1.png const IMG_ARRAY = [ { id: 'IMG_HASH_1PX', name: '1x1.png', hash: IMG_HASH_1PX }, @@ -20,10 +21,16 @@ const readPublicSubdomainGatewaySetting = () => { return setting || DEFAULT_SUBDOMAIN_GATEWAY } +const readIpfsCheckUrlSetting = () => { + const setting = readSetting('ipfsCheckUrl') + return setting || DEFAULT_IPFS_CHECK_URL +} + const init = () => ({ availableGateway: null, publicGateway: readPublicGatewaySetting(), - publicSubdomainGateway: readPublicSubdomainGatewaySetting() + publicSubdomainGateway: readPublicSubdomainGatewaySetting(), + ipfsCheckUrl: readIpfsCheckUrlSetting() }) export const checkValidHttpUrl = (value) => { @@ -169,6 +176,10 @@ const bundle = { return { ...state, publicSubdomainGateway: action.payload } } + if (action.type === 'SET_IPFS_CHECK_URL') { + return { ...state, ipfsCheckUrl: action.payload } + } + return state }, @@ -184,11 +195,18 @@ const bundle = { dispatch({ type: 'SET_PUBLIC_SUBDOMAIN_GATEWAY', payload: address }) }, + doUpdateIpfsCheckUrl: (url) => async ({ dispatch }) => { + await writeSetting('ipfsCheckUrl', url) + dispatch({ type: 'SET_IPFS_CHECK_URL', payload: url }) + }, + selectAvailableGateway: (state) => state?.gateway?.availableGateway, selectPublicGateway: (state) => state?.gateway?.publicGateway, - selectPublicSubdomainGateway: (state) => state?.gateway?.publicSubdomainGateway + selectPublicSubdomainGateway: (state) => state?.gateway?.publicSubdomainGateway, + + selectIpfsCheckUrl: (state) => state?.gateway?.ipfsCheckUrl } export default bundle diff --git a/src/bundles/index.js b/src/bundles/index.js index 5e6f8312d..df5d6ad1d 100644 --- a/src/bundles/index.js +++ b/src/bundles/index.js @@ -1,4 +1,4 @@ -import { composeBundles, createCacheBundle } from 'redux-bundler' +import { composeBundles, createCacheBundle, createSelector } from 'redux-bundler' import ipfsProvider from './ipfs-provider.js' import appIdle from './app-idle.js' import nodeBandwidthChartBundle from './node-bandwidth-chart.js' @@ -23,8 +23,18 @@ import experimentsBundle from './experiments.js' import cliTutorModeBundle from './cli-tutor-mode.js' import gatewayBundle from './gateway.js' import ipnsBundle from './ipns.js' +import { contextBridge } from '../helpers/context-bridge' export default composeBundles( + { + name: 'bridgedContextCatchAll', + reactRouteInfoToBridge: createSelector( + 'selectRouteInfo', + (routeInfo) => { + contextBridge.setContext('selectRouteInfo', routeInfo) + } + ) + }, createCacheBundle({ cacheFn: bundleCache.set }), diff --git a/src/bundles/routes-types.ts b/src/bundles/routes-types.ts new file mode 100644 index 000000000..c2c8faf1d --- /dev/null +++ b/src/bundles/routes-types.ts @@ -0,0 +1,33 @@ +import type React from 'react' + +/** + * src/bundles/routes.js creates a RouteBundle from redux-bundler that provides some objects that are not typed. + */ + +/** + * The type for the object provided by `selectRouteInfo` selector. + * + * These types are not 100% accurate and only filled out as accurately as possible as needed. + */ +export interface RouteInfo { + /** + * The value of the currently matched pattern from src/bundles/routes.js + */ + page: React.ReactNode + + params: { + // if you are on #/diagnostics/logs, this will be equal to '/logs' + path: string + } + + /** + * This will match whatever key is set in src/bundles/routes.js for the page that is currently active. + * For the diagnostics page, this will be equal to '/diagnostics*' + */ + pattern: string + + /** + * The hash of the url, without the hash symbol. + */ + url: string +} diff --git a/src/bundles/routes.js b/src/bundles/routes.js index 2524925c9..5b1915c9e 100644 --- a/src/bundles/routes.js +++ b/src/bundles/routes.js @@ -22,7 +22,7 @@ export default createRouteBundle({ '/settings*': SettingsPage, '/welcome': WelcomePage, '/blank': BlankPage, - '/diagnostics': DiagnosticsPage, + '/diagnostics*': DiagnosticsPage, '/status*': StatusPage, '/': StatusPage, '': StatusPage diff --git a/src/components/ipfs-check-form/IpfsCheckForm.js b/src/components/ipfs-check-form/IpfsCheckForm.js new file mode 100644 index 000000000..3e569a267 --- /dev/null +++ b/src/components/ipfs-check-form/IpfsCheckForm.js @@ -0,0 +1,90 @@ +import React, { useState, useEffect } from 'react' +import { connect } from 'redux-bundler-react' +import { withTranslation } from 'react-i18next' +import Button from '../button/button.tsx' +import { checkValidHttpUrl, DEFAULT_IPFS_CHECK_URL } from '../../bundles/gateway.js' + +const IpfsCheckForm = ({ t, doUpdateIpfsCheckUrl, ipfsCheckUrl }) => { + const [value, setValue] = useState(ipfsCheckUrl) + const initialIsValidUrl = !checkValidHttpUrl(value) + const [showFailState, setShowFailState] = useState(initialIsValidUrl) + const [isValidUrl, setIsValidUrl] = useState(initialIsValidUrl) + + // Updates the border of the input to indicate validity + useEffect(() => { + setShowFailState(!isValidUrl) + }, [isValidUrl]) + + // Updates the border of the input to indicate validity + useEffect(() => { + const isValid = checkValidHttpUrl(value) + setIsValidUrl(isValid) + setShowFailState(!isValid) + }, [value]) + + const onChange = (event) => setValue(event.target.value) + + const onSubmit = async (event) => { + event.preventDefault() + + if (!isValidUrl) { + setShowFailState(true) + return + } + + doUpdateIpfsCheckUrl(value) + } + + const onDefault = async (event) => { + event.preventDefault() + setValue(DEFAULT_IPFS_CHECK_URL) + doUpdateIpfsCheckUrl(DEFAULT_IPFS_CHECK_URL) + } + + const onKeyPress = (event) => { + if (event.key === 'Enter') { + onSubmit(event) + } + } + + return ( +
+ +
+ + +
+
+ ) +} + +export default connect( + 'doUpdateIpfsCheckUrl', + 'selectIpfsCheckUrl', + withTranslation('app')(IpfsCheckForm) +) diff --git a/src/diagnostics/check-screen/check-screen.tsx b/src/diagnostics/check-screen/check-screen.tsx new file mode 100644 index 000000000..847809e54 --- /dev/null +++ b/src/diagnostics/check-screen/check-screen.tsx @@ -0,0 +1,88 @@ +import React, { useRef, useEffect, useState } from 'react' +import { connect } from 'redux-bundler-react' +import { useDebouncedCallback } from '../../lib/hooks/use-debounced-callback' +import { DEFAULT_IPFS_CHECK_URL } from '../../bundles/gateway.js' + +interface CheckScreenProps { + cid?: string + ipfsCheckUrl?: string +} + +const CheckScreen: React.FC = ({ cid, ipfsCheckUrl }) => { + const ipfsCheckBaseUrl = ipfsCheckUrl || DEFAULT_IPFS_CHECK_URL + const baseUrl = ipfsCheckBaseUrl.endsWith('/') ? ipfsCheckBaseUrl : `${ipfsCheckBaseUrl}/` + const ipfsCheckOrigin = new URL(baseUrl).origin + const ref = useRef(null) + const [isLoading, setIsLoading] = useState(true) + + const requestSize = useDebouncedCallback(() => { + const iframe = ref.current + if (!iframe) return + ref.current?.contentWindow?.postMessage({ type: 'iframe-size:request' }, '*') + }, 100) + + useEffect(() => { + const iframe = ref.current + if (!iframe) return + + const onMsg = (e: MessageEvent) => { + // Validate origin to prevent XSS attacks + if (e.origin !== ipfsCheckOrigin) return + if (e.data?.type !== 'iframe-size:report') return + + // Hide loading message as soon as we get first message from iframe + setIsLoading(false) + + // Update iframe height based on content + iframe.style.height = `${e.data.height}px` + } + const onLoad = () => { + setIsLoading(false) + requestSize() + } + + window.addEventListener('message', onMsg) + window.addEventListener('resize', requestSize) + iframe.addEventListener('load', onLoad) + + // initial size request since the iframe ref is available. + requestSize() + + return () => { + window.removeEventListener('message', onMsg) + window.removeEventListener('resize', requestSize) + iframe.removeEventListener('load', onLoad) + } + }, [requestSize, ipfsCheckOrigin]) + + // Build the iframe URL with optional CID parameter + const iframeSrc = cid ? `${baseUrl}?cid=${encodeURIComponent(cid)}` : baseUrl + + return ( +
+ {isLoading && ( +
+

Loading retrieval check...

+
+ )} +