Skip to content

Commit

Permalink
feat: ability to use custom public gateway (#1834)
Browse files Browse the repository at this point in the history
* fix: local gateway and ipfs.io when cannot preview

License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>

* custom public gateway

* fix: opening non-unixfs DAG

License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>

* fix: use custom public gateway on shareable links

License: MIT
Signed-off-by: Henrique Dias <hacdias@gmail.com>

* fix: ux of submit/reset

cosmetic fix that adds reset button to public gateway section in
Settings and disables buttons when they are no-op.

* style: public gateway description

shareable links are better way to sonvey the practical meaning of this
setting, other places are incidental

Co-authored-by: Marcin Rataj <lidel@lidel.org>
  • Loading branch information
hacdias and lidel authored Sep 1, 2021
1 parent d43dc62 commit 4bf78a4
Show file tree
Hide file tree
Showing 14 changed files with 218 additions and 58 deletions.
4 changes: 4 additions & 0 deletions public/locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
"apiAddressForm": {
"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://dweb.link)"
},
"terms": {
"address": "Address",
"addresses": "Addresses",
Expand Down Expand Up @@ -75,6 +78,7 @@
"pins": "Pins",
"pinStatus": "Pin Status",
"publicKey": "Public key",
"publicGateway": "Public Gateway",
"rateIn": "Rate in",
"rateOut": "Rate out",
"repo": "Repo",
Expand Down
5 changes: 3 additions & 2 deletions public/locales/en/files.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"individualFilesOnly": "Only available for individual files",
"noPDFSupport": "Your browser does not support PDFs. Please download the PDF to view it:",
"downloadPDF": "Download PDF",
"downloadInstead": "Try <1>downloading</1> it instead.",
"openWithPublicGateway": "Try opening it instead with your <1>public gateway</1>.",
"openWithLocalAndPublicGateway": "Try opening it instead with your <1>local gateway</1> or <3>public gateway</3>.",
"cantBePreviewed": "Sorry, this file can’t be previewed",
"addByPath": "From IPFS",
"newFolder": "New folder",
Expand Down Expand Up @@ -109,6 +110,6 @@
"filesDescription": "Total size of data in the current directory (if a subdirectory, the size of all data in Files is also displayed)",
"more": "More",
"files": "Files",
"cidNotFileNorDir": "The current link isn't a file, nor a directory. Try to <0>inspect</0> it instead.",
"cidNotFileNorDir": "The current link isn't a file, nor a directory. Try to <1>inspect</1> it instead.",
"sortBy": "Sort items by {name}"
}
1 change: 1 addition & 0 deletions public/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"translationProjectLink": "Join the IPFS Translation Project"
},
"apiDescription": "<0>If your node is configured with a <1>custom 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>",
"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
13 changes: 8 additions & 5 deletions src/bundles/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import memoize from 'p-memoize'
import toUri from 'multiaddr-to-uri'
import { createAsyncResourceBundle, createSelector } from 'redux-bundler'

const DEFAULT_URI = 'https://ipfs.io'
const LOCAL_HOSTNAMES = ['127.0.0.1', '[::1]', '0.0.0.0', '[::]']

const bundle = createAsyncResourceBundle({
Expand All @@ -22,7 +21,9 @@ const bundle = createAsyncResourceBundle({
}

const config = JSON.parse(conf)
const url = getURLFromAddress('Gateway', config) || DEFAULT_URI

const publicGateway = store.selectPublicGateway()
const url = getURLFromAddress('Gateway', config) || publicGateway

// Normalize local hostnames to localhost
// to leverage subdomain gateway, if present
Expand All @@ -38,7 +39,7 @@ const bundle = createAsyncResourceBundle({
}

if (!await checkIfGatewayUrlIsAccessible(url)) {
store.doSetAvailableGateway(DEFAULT_URI)
store.doSetAvailableGateway(publicGateway)
}

// stringy json for quick compares
Expand All @@ -54,12 +55,14 @@ bundle.selectConfigObject = createSelector(

bundle.selectApiUrl = createSelector(
'selectConfigObject',
(config) => getURLFromAddress('API', config) || DEFAULT_URI
'selectPublicGateway',
(config, publicGateway) => getURLFromAddress('API', config) || publicGateway
)

bundle.selectGatewayUrl = createSelector(
'selectConfigObject',
(config) => getURLFromAddress('Gateway', config) || DEFAULT_URI
'selectPublicGateway',
(config, publicGateway) => getURLFromAddress('Gateway', config) || publicGateway
)

bundle.selectAvailableGatewayUrl = createSelector(
Expand Down
6 changes: 4 additions & 2 deletions src/bundles/files/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ const getPins = async function * (ipfs) {
* @typedef {Object} ConfigSelectors
* @property {function():string} selectApiUrl
* @property {function():string} selectGatewayUrl
* @property {function():string} selectPublicGateway
*
* @typedef {Object} UnkonwActions
* @property {function(string):Promise<unknown>} doUpdateHash
Expand Down Expand Up @@ -421,9 +422,10 @@ const actions = () => ({
* Generates sharable link for the provided files.
* @param {FileStat[]} files
*/
doFilesShareLink: (files) => perform(ACTIONS.SHARE_LINK, async (ipfs) => {
doFilesShareLink: (files) => perform(ACTIONS.SHARE_LINK, async (ipfs, { store }) => {
// ensureMFS deliberately omitted here, see https://github.com/ipfs/ipfs-webui/issues/1744 for context.
return await getShareableLink(files, ipfs)
const publicGateway = store.selectPublicGateway()
return await getShareableLink(files, publicGateway, ipfs)
}),

/**
Expand Down
41 changes: 39 additions & 2 deletions src/bundles/gateway.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,54 @@
import { readSetting, writeSetting } from './local-storage'

export const DEFAULT_GATEWAY = 'https://dweb.link'

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

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

export const checkValidHttpUrl = (value) => {
let url

try {
url = new URL(value)
} catch (_) {
return false
}

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

const bundle = {
name: 'gateway',

reducer: (state = { availableGateway: null }, action) => {
reducer: (state = init(), action) => {
if (action.type === 'SET_AVAILABLE_GATEWAY') {
return { ...state, availableGateway: action.payload }
}

if (action.type === 'SET_PUBLIC_GATEWAY') {
return { ...state, publicGateway: action.payload }
}

return state
},

doSetAvailableGateway: url => ({ dispatch }) => dispatch({ type: 'SET_AVAILABLE_GATEWAY', payload: url }),

selectAvailableGateway: (state) => state?.gateway?.availableGateway
doUpdatePublicGateway: (address) => async ({ dispatch }) => {
await writeSetting('ipfsPublicGateway', address)
dispatch({ type: 'SET_PUBLIC_GATEWAY', payload: address })
},

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

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

export default bundle
40 changes: 1 addition & 39 deletions src/bundles/ipfs-provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import first from 'it-first'
import last from 'it-last'
import * as Enum from './enum'
import { perform } from './task'
import { readSetting, writeSetting } from './local-storage'

/* TODO: restore when no longer bundle standalone ipld with ipld-explorer
* context: https://github.com/ipfs/ipld-explorer-components/pull/289
Expand Down Expand Up @@ -273,45 +274,6 @@ const readHTTPClientOptions = (value) => {
}
}

/**
* Reads setting from the `localStorage` with a given `id` as JSON. If JSON
* parse is failed setting is interpreted as a string value.
* @param {string} id
* @returns {string|object|null}
*/
const readSetting = (id) => {
/** @type {string|null} */
let setting = null
if (window.localStorage) {
try {
setting = window.localStorage.getItem(id)
} catch (error) {
console.error(`Error reading '${id}' value from localStorage`, error)
}

try {
return JSON.parse(setting || '')
} catch (_) {
// res was probably a string, so pass it on.
return setting
}
}

return setting
}

/**
* @param {string} id
* @param {string|number|boolean|object} value
*/
const writeSetting = (id, value) => {
try {
window.localStorage.setItem(id, JSON.stringify(value))
} catch (error) {
console.error(`Error writing '${id}' value to localStorage`, error)
}
}

/** @type {IPFSService|null} */
let ipfs = null

Expand Down
38 changes: 38 additions & 0 deletions src/bundles/local-storage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Reads setting from the `localStorage` with a given `id` as JSON. If JSON
* parse is failed setting is interpreted as a string value.
* @param {string} id
* @returns {string|object|null}
*/
export const readSetting = (id) => {
/** @type {string|null} */
let setting = null
if (window.localStorage) {
try {
setting = window.localStorage.getItem(id)
} catch (error) {
console.error(`Error reading '${id}' value from localStorage`, error)
}

try {
return JSON.parse(setting || '')
} catch (_) {
// res was probably a string, so pass it on.
return setting
}
}

return setting
}

/**
* @param {string} id
* @param {string|number|boolean|object} value
*/
export const writeSetting = (id, value) => {
try {
window.localStorage.setItem(id, JSON.stringify(value))
} catch (error) {
console.error(`Error writing '${id}' value to localStorage`, error)
}
}
8 changes: 7 additions & 1 deletion src/components/api-address-form/ApiAddressForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@ const ApiAddressForm = ({ t, doUpdateIpfsApiAddress, ipfsApiAddress, ipfsInitFai
value={value}
/>
<div className='tr'>
<Button className='tc' disabled={!isValidApiAddress}>{t('actions.submit')}</Button>
<Button
minWidth={100}
height={40}
className='mt2 mt0-l ml2-l tc'
disabled={!isValidApiAddress || value === ipfsApiAddress}>
{t('actions.submit')}
</Button>
</div>
</form>
)
Expand Down
82 changes: 82 additions & 0 deletions src/components/public-gateway-form/PublicGatewayForm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useState, useEffect } from 'react'
import { connect } from 'redux-bundler-react'
import { withTranslation } from 'react-i18next'
import Button from '../button/Button'
import { checkValidHttpUrl, DEFAULT_GATEWAY } from '../../bundles/gateway'

const PublicGatewayForm = ({ t, doUpdatePublicGateway, publicGateway }) => {
const [value, setValue] = useState(publicGateway)
const initialIsValidGatewayUrl = !checkValidHttpUrl(value)
const [showFailState, setShowFailState] = useState(initialIsValidGatewayUrl)
const [isValidGatewayUrl, setIsValidGatewayUrl] = useState(initialIsValidGatewayUrl)

// Updates the border of the input to indicate validity
useEffect(() => {
setShowFailState(!isValidGatewayUrl)
}, [isValidGatewayUrl])

// Updates the border of the input to indicate validity
useEffect(() => {
const isValid = checkValidHttpUrl(value)
setIsValidGatewayUrl(isValid)
setShowFailState(!isValid)
}, [value])

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

const onSubmit = async (event) => {
event.preventDefault()
doUpdatePublicGateway(value)
}

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

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

return (
<form onSubmit={onSubmit}>
<input
id='public-gateway'
aria-label={t('terms.publicGateway')}
placeholder={t('publicGatewayForm.placeholder')}
type='text'
className={`w-100 lh-copy monospace f5 pl1 pv1 mb2 charcoal input-reset ba b--black-20 br1 ${showFailState ? 'focus-outline-red b--red-muted' : 'focus-outline-green b--green-muted'}`}
onChange={onChange}
onKeyPress={onKeyPress}
value={value}
/>
<div className='tr'>
<Button
minWidth={100}
height={40}
bg='bg-charcoal'
className='tc'
disabled={value === DEFAULT_GATEWAY}
onClick={onReset}>
{t('app:actions.reset')}
</Button>
<Button
minWidth={100}
height={40}
className='mt2 mt0-l ml2-l tc'
disabled={!isValidGatewayUrl || value === publicGateway}>
{t('actions.submit')}
</Button>
</div>
</form>
)
}

export default connect(
'doUpdatePublicGateway',
'selectPublicGateway',
withTranslation('app')(PublicGatewayForm)
)
Loading

0 comments on commit 4bf78a4

Please sign in to comment.