Merge pull request #191 from oceanprotocol/feature/ipfs

Add files to IPFS during publish flow
This commit is contained in:
Matthias Kretschmann 2019-10-14 15:22:51 +02:00 committed by GitHub
commit 54a4ba5c68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 9033 additions and 511 deletions

View File

@ -11,10 +11,11 @@
"extends": [
"oceanprotocol",
"oceanprotocol/react",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"prettier/react",
"prettier/standard",
"plugin:prettier/recommended",
"plugin:@typescript-eslint/recommended",
"prettier/@typescript-eslint",
"plugin:cypress/recommended"
],

1
.gitignore vendored
View File

@ -26,4 +26,5 @@ yarn-error.log*
cypress/screenshots
cypress/videos
cypress/fixtures/did.txt
cypress/fixtures/did-ipfs.txt
artifacts

View File

@ -26,8 +26,14 @@ env:
- REACT_APP_SECRET_STORE_URI="http://localhost:12001"
- REACT_APP_FAUCET_URI="http://localhost:3001"
- REACT_APP_BRIZO_ADDRESS="0x068ed00cf0441e4829d9784fcbe7b9e26d4bd8d0"
# IPFS client & server config
- REACT_APP_IPFS_GATEWAY_URI="https://ipfs.oceanprotocol.com"
- REACT_APP_IPFS_NODE_URI="https://ipfs.oceanprotocol.com:443"
- IPFS_GATEWAY_URI="https://ipfs.oceanprotocol.com"
# start Barge with these versions
- BRIZO_VERSION=v0.4.2
- BRIZO_VERSION=v0.4.5
- AQUARIUS_VERSION=v0.3.8
- KEEPER_VERSION=v0.11.1
- EVENTS_HANDLER_VERSION=v0.1.2
@ -57,16 +63,14 @@ script:
- ./scripts/keeper.sh
- ./scripts/test.sh || travis_terminate 1
- ./scripts/coverage.sh
# Pipe the coverage data to Code Climate
- ./cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.client.json client/coverage/lcov.info
- ./cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.server.json server/coverage/lcov.info
- ./cc-test-reporter sum-coverage coverage/codeclimate.*.json -p 2
- if [[ "$TRAVIS_TEST_RESULT" == 0 ]]; then ./cc-test-reporter upload-coverage; fi
- npm run test:e2e || travis_terminate 1
- ./scripts/build.sh
# Pipe the coverage data to Code Climate
after_script:
- ./cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.client.json client/coverage/lcov.info # Format client coverage
- ./cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.server.json server/coverage/lcov.info # Format server coverage
- ./cc-test-reporter sum-coverage coverage/codeclimate.*.json -p 2 # Sum both coverage parts into coverage/codeclimate.json
- if [[ "$TRAVIS_TEST_RESULT" == 0 ]]; then ./cc-test-reporter upload-coverage; fi # Upload coverage/codeclimate.json
notifications:
email: false

View File

@ -31,6 +31,7 @@ If you're a developer and want to contribute to, or want to utilize this marketp
- [Client](#client)
- [Server](#server)
- [Feature Switches](#feature-switches)
- [More Settings](#more-settings)
- [👩‍🔬 Testing](#-testing)
- [Unit Tests](#unit-tests)
- [End-to-End Integration Tests](#end-to-end-integration-tests)
@ -144,6 +145,14 @@ Beside configuring the network endpopints, the client allows to activate some fe
| `REACT_APP_SHOW_REQUEST_TOKENS_BUTTON` | Shows a second button on the `/faucet` route to request Ocean Tokens in addition to Ether. Will only work in Ocean testnets. |
| `REACT_APP_ALLOW_PRICING` | Activate pricing feature. Will show a price input during publish flow, and output prices for each data asset. |
#### More Settings
| Env Variable | Example | Feature Description |
| --------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------- |
| client: `REACT_APP_IPFS_GATEWAY_URI`<br /> server: `IPFS_GATEWAY_URI` | `"https://ipfs.oceanprotocol.com"` | The IPFS gateway URI. |
| `REACT_APP_IPFS_NODE_URI` | `"https://ipfs.oceanprotocol.com:443"` | The IPFS node URI used to add files to IPFS. |
| `REACT_APP_REPORT_EMAIL` | `"jelly@mcjellyfish.com"` | The email used for the _report an asset_ feature. |
## 👩‍🔬 Testing
Test suite is setup with [Jest](https://jestjs.io) and [react-testing-library](https://github.com/kentcdodds/react-testing-library) for unit testing, and [Cypress](https://www.cypress.io) for integration testing.

View File

@ -59,3 +59,5 @@ REACT_APP_REPORT_EMAIL="test@example.com"
# REACT_APP_SHOW_CHANNELS=true
# REACT_APP_ALLOW_PRICING=true
# REACT_APP_SHOW_REQUEST_TOKENS_BUTTON=true
REACT_APP_IPFS_GATEWAY_URI="https://ipfs.oceanprotocol.com"
REACT_APP_IPFS_NODE_URI="https://ipfs.oceanprotocol.com:443"

8102
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@
},
"dependencies": {
"@oceanprotocol/art": "^2.2.0",
"@oceanprotocol/squid": "^0.7.2",
"@oceanprotocol/squid": "^0.7.3",
"@oceanprotocol/typographies": "^0.1.0",
"@sindresorhus/slugify": "^0.9.1",
"axios": "^0.19.0",
@ -23,7 +23,9 @@
"ethereum-blockies": "github:MyEtherWallet/blockies",
"filesize": "^4.1.2",
"history": "^4.9.0",
"is-url": "^1.2.4",
"ipfs": "^0.38.0",
"ipfs-http-client": "^38.2.0",
"is-url-superb": "^3.0.0",
"moment": "^2.24.0",
"query-string": "^6.8.2",
"react": "16.8.6",
@ -31,6 +33,7 @@
"react-datepicker": "^2.8.0",
"react-dom": "16.8.6",
"react-dotdotdot": "^1.3.1",
"react-dropzone": "^10.1.8",
"react-ga": "^2.6.0",
"react-helmet": "^5.2.1",
"react-markdown": "^4.1.0",

View File

@ -0,0 +1 @@
declare module 'ipfs-http-client'

1
client/src/@types/ipfs/index.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'ipfs'

View File

@ -1,4 +1,4 @@
import React, { PureComponent } from 'react'
import React from 'react'
import { Link } from 'react-router-dom'
import cx from 'classnames'
import styles from './Button.module.scss'
@ -15,47 +15,34 @@ interface ButtonProps {
name?: string
}
export default class Button extends PureComponent<ButtonProps, any> {
public render() {
let classes
const {
primary,
link,
href,
children,
className,
to,
...props
} = this.props
if (primary) {
classes = styles.buttonPrimary
} else if (link) {
classes = styles.link
} else {
classes = styles.button
}
if (to) {
return (
<Link to={to} className={cx(classes, className)} {...props}>
{children}
</Link>
)
}
if (href) {
return (
<a href={href} className={cx(classes, className)} {...props}>
{children}
</a>
)
}
return (
<button className={cx(classes, className)} {...props}>
{children}
</button>
)
}
function getClasses(primary: boolean | undefined, link: boolean | undefined) {
return primary ? styles.buttonPrimary : link ? styles.link : styles.button
}
const Button = ({
primary,
link,
href,
children,
className,
to,
...props
}: ButtonProps) => {
const classes = getClasses(primary, link)
return to ? (
<Link to={to} className={cx(classes, className)} {...props}>
{children}
</Link>
) : href ? (
<a href={href} className={cx(classes, className)} {...props}>
{children}
</a>
) : (
<button className={cx(classes, className)} {...props}>
{children}
</button>
)
}
export default Button

View File

@ -1,5 +1,5 @@
import React from 'react'
import { render } from '@testing-library/react'
import { render, fireEvent } from '@testing-library/react'
import Input from './Input'
describe('Input', () => {
@ -7,10 +7,10 @@ describe('Input', () => {
const { container } = render(<Input name="my-input" label="My Input" />)
expect(container.firstChild).toBeInTheDocument()
expect(container.querySelector('.label')).toHaveTextContent('My Input')
expect(container.querySelector('.input')).toHaveAttribute(
'id',
'my-input'
)
const input = container.querySelector('.input')
expect(input).toHaveAttribute('id', 'my-input')
input && fireEvent.focus(input)
})
it('renders as text input by default', () => {
@ -25,13 +25,13 @@ describe('Input', () => {
const { container } = render(
<Input name="my-input" label="My Input" type="search" />
)
expect(container.querySelector('.input')).toHaveAttribute(
'type',
'search'
)
const input = container.querySelector('.input')
expect(input).toHaveAttribute('type', 'search')
expect(container.querySelector('label + div')).toHaveClass(
'inputWrapSearch'
)
input && fireEvent.focus(input)
})
it('renders select', () => {

View File

@ -30,6 +30,7 @@ interface InputProps {
rows?: number
group?: any
multiple?: boolean
pattern?: string
}
interface InputState {

View File

@ -0,0 +1,15 @@
import React from 'react'
import { render } from '@testing-library/react'
import Spinner from './Spinner'
describe('Spinner', () => {
it('renders without crashing', () => {
const { container } = render(<Spinner />)
expect(container.firstChild).toBeInTheDocument()
})
it('renders small variant', () => {
const { container } = render(<Spinner small />)
expect(container.firstChild).toBeInTheDocument()
})
})

View File

@ -7,7 +7,7 @@
padding: .5rem;
}
// default: red square
/* default: red square */
.statusIndicator {
width: $font-size-small;
height: $font-size-small;
@ -15,7 +15,7 @@
background: $red;
}
// yellow triangle
/* yellow triangle */
.statusIndicatorCloseEnough {
composes: statusIndicator;
background: none;
@ -26,7 +26,7 @@
border-bottom: $font-size-small solid $yellow;
}
// green circle
/* green circle */
.statusIndicatorActive {
composes: statusIndicator;
border-radius: 50%;

View File

@ -0,0 +1,45 @@
@import '../../styles/variables';
.dropzone {
margin-top: $spacer;
margin-bottom: $spacer;
border: .2rem dashed $brand-grey-lighter;
border-radius: $border-radius * 2;
padding: $spacer;
background: $brand-white;
transition: .2s ease-out;
cursor: pointer;
p {
text-align: center;
margin-bottom: 0;
font-size: $font-size-small;
color: $brand-grey-light;
}
&:hover,
&:focus,
&:active {
border-color: $brand-grey-light;
outline: 0;
}
}
.dragover {
composes: dropzone;
border-color: $brand-pink;
}
.disabled {
composes: dropzone;
opacity: .5;
pointer-events: none;
}
.dropzoneFiles {
padding: $spacer 0;
ul {
margin: 0;
}
}

View File

@ -0,0 +1,52 @@
import React from 'react'
import { fireEvent, render } from '@testing-library/react'
import Dropzone from './Dropzone'
function mockData(files: any) {
return {
dataTransfer: {
files,
items: files.map((file: any) => ({
kind: 'file',
type: file.type,
getAsFile: () => file
})),
types: ['Files']
}
}
}
function flushPromises(ui: any, container: any) {
return new Promise(resolve =>
setImmediate(() => {
render(ui, { container })
resolve(container)
})
)
}
function dispatchEvt(node: any, type: string, data: any) {
const event = new Event(type, { bubbles: true })
Object.assign(event, data)
fireEvent(node, event)
}
test('invoke onDragEnter when dragenter event occurs', async () => {
const file = new File([JSON.stringify({ ping: true })], 'ping.json', {
type: 'application/json'
})
const data = mockData([file])
const handleOnDrop = jest.fn()
const ui = <Dropzone handleOnDrop={handleOnDrop} />
const { container } = render(ui)
// drop a file
const dropzone = container.querySelector('div')
dispatchEvt(dropzone, 'dragenter', data)
dispatchEvt(dropzone, 'dragover', data)
dispatchEvt(dropzone, 'drop', data)
await flushPromises(ui, container)
expect(handleOnDrop).toHaveBeenCalled()
})

View File

@ -0,0 +1,62 @@
import React, { useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import styles from './Dropzone.module.scss'
import { formatBytes } from '../../utils/utils'
export default function Dropzone({
handleOnDrop,
disabled,
multiple
}: {
handleOnDrop(files: File[]): void
disabled?: boolean
multiple?: boolean
}) {
const onDrop = useCallback(acceptedFiles => handleOnDrop(acceptedFiles), [
handleOnDrop
])
const {
acceptedFiles,
getRootProps,
getInputProps,
isDragActive,
isDragReject
} = useDropzone({ onDrop })
const files = acceptedFiles.map((file: any) => (
<li key={file.path}>
{file.path} - {formatBytes(file.size, 0)}
</li>
))
return (
<>
{acceptedFiles.length > 0 ? (
<aside className={styles.dropzoneFiles}>
<ul>{files}</ul>
</aside>
) : (
<div
{...getRootProps({
className: isDragActive
? styles.dragover
: disabled
? styles.disabled
: styles.dropzone
})}
>
<input {...getInputProps({ multiple })} />
<p>
{isDragActive && !isDragReject
? `Drop it like it's hot!`
: multiple
? `Drag 'n' drop some files here, or click to select files`
: `Drag 'n' drop a file here, or click to select a file`}
{}
</p>
</div>
)}
</>
)
}

View File

@ -7,7 +7,7 @@ import { userMockConnected } from '../../../__mocks__/user-mock'
import { marketMock } from '../../../__mocks__/market-mock'
describe('WalletSelector', () => {
it('renders without crashing', async () => {
it('renders without crashing', () => {
ReactModal.setAppElement(document.createElement('div'))
const { container, getByText } = render(
@ -20,11 +20,11 @@ describe('WalletSelector', () => {
expect(container.firstChild).toBeInTheDocument()
fireEvent.click(getByText('Select wallet'))
const burnerButton = await getByText('Burner Wallet')
const burnerButton = getByText('Burner Wallet')
fireEvent.click(burnerButton)
fireEvent.click(getByText('Select wallet'))
// const metamaskButton = await getByText('MetaMask')
// const metamaskButton = getByText('MetaMask')
// fireEvent.click(metamaskButton)
})
})

View File

@ -34,7 +34,30 @@ describe('Report', () => {
expect(comment).toHaveTextContent('Plants')
fireEvent.click(getByTestId('report'))
mockAxios.mockResponse(mockResponse)
// expect(mockAxios.post).toHaveBeenCalled()
expect(mockAxios).toHaveBeenCalled()
// close modal
fireEvent.click(getByTestId('closeModal'))
})
it('catches response error', async () => {
ReactModal.setAppElement(document.createElement('div'))
const { getByText, getByLabelText, getByTestId } = render(
<Report did="did:xxx" title="Hello" />
)
// open modal
fireEvent.click(getByText('Report Data Set'))
await wait(() => expect(getByText('did:xxx')).toBeInTheDocument())
// add comment
const comment = getByLabelText('Comment')
fireEvent.change(comment, {
target: { value: 'Plants' }
})
expect(comment).toHaveTextContent('Plants')
fireEvent.click(getByTestId('report'))
mockAxios.mockError({ message: 'Error catch' })
// close modal
fireEvent.click(getByTestId('closeModal'))

View File

@ -35,3 +35,8 @@ export const allowPricing =
process.env.REACT_APP_ALLOW_PRICING === 'true' || false
export const showRequestTokens =
process.env.REACT_APP_SHOW_REQUEST_TOKENS_BUTTON === 'true' || false
// https://ipfs.github.io/public-gateway-checker/
export const ipfsGatewayUri =
process.env.REACT_APP_IPFS_GATEWAY_URI || 'https://gateway.ipfs.io'
export const ipfsNodeUri =
process.env.REACT_APP_IPFS_NODE_URI || 'https://ipfs.infura.io:5001'

View File

@ -0,0 +1,54 @@
/* eslint-disable no-console */
import { useEffect, useState } from 'react'
import ipfsClient from 'ipfs-http-client'
let ipfs: any = null
let ipfsMessage = ''
let ipfsVersion = ''
export interface IpfsConfig {
protocol: string
host: string
port: string
}
export default function useIpfsApi(config: IpfsConfig) {
const [isIpfsReady, setIpfsReady] = useState(Boolean(ipfs))
const [ipfsError, setIpfsError] = useState('')
useEffect(() => {
async function initIpfs() {
if (ipfs !== null) return
ipfsMessage = 'Checking IPFS gateway...'
try {
ipfs = await ipfsClient(config)
const version = await ipfs.version()
ipfsVersion = version.version
ipfsMessage = `Connected to ${config.host}`
} catch (error) {
setIpfsError(`IPFS connection error: ${error.message}`)
}
setIpfsReady(Boolean(await ipfs.id()))
}
initIpfs()
}, [config])
useEffect(() => {
// just like componentWillUnmount()
return function cleanup() {
if (ipfs) {
setIpfsReady(false)
ipfs = null
ipfsMessage = ''
ipfsVersion = ''
setIpfsError('')
}
}
}, [])
return { ipfs, ipfsVersion, isIpfsReady, ipfsError, ipfsMessage }
}

View File

@ -0,0 +1,22 @@
import React from 'react'
import { render, wait } from '@testing-library/react'
import useIpfs from './use-ipfs'
export default function TestComponent() {
const { ipfsVersion, isIpfsReady, ipfsError, ipfsMessage } = useIpfs()
return (
<div>
{isIpfsReady && <span>Ready</span>}
{ipfsVersion} - {ipfsMessage} - {ipfsError}
</div>
)
}
describe('use-ipfs', () => {
it('renders without crashing', async () => {
const { container, getByText } = render(<TestComponent />)
expect(container.firstChild).toBeInTheDocument()
await wait(() => getByText('Ready'))
})
})

View File

@ -0,0 +1,74 @@
/* eslint-disable no-console */
import Ipfs from 'ipfs'
import { useEffect, useState } from 'react'
import os from 'os'
import shortid from 'shortid'
let ipfs: any = null
let ipfsMessage = ''
let ipfsVersion = ''
export default function useIpfs() {
const [isIpfsReady, setIpfsReady] = useState(Boolean(ipfs))
const [ipfsError, setIpfsError] = useState('')
useEffect(() => {
async function startIpfs() {
ipfsMessage = 'Starting IPFS...'
if (ipfs) {
console.log('IPFS already started')
// } else if (window.ipfs && window.ipfs.enable) {
// console.log('Found window.ipfs')
// ipfs = await window.ipfs.enable()
} else {
try {
const message = 'IPFS started'
console.time(message)
ipfs = await Ipfs.create({
repo: `${os.homedir()}/.jsipfs-${shortid.generate()}`,
config: {
Addresses: {
// 0 for port so system just assigns a new free port
// to allow multiple nodes running at same time
Swarm: ['/ip4/0.0.0.0/tcp/0']
}
}
})
console.timeEnd(message)
ipfsMessage = message
const { agentVersion } = await ipfs.id()
ipfsVersion = agentVersion
} catch (error) {
const message = `IPFS init error: ${error.message}`
ipfsMessage = message
console.error(message)
ipfs = null
setIpfsError(error.message)
}
}
setIpfsReady(Boolean(ipfs))
}
startIpfs()
// just like componentWillUnmount()
return function cleanup() {
if (ipfs && ipfs.stop) {
console.time('IPFS stopped')
ipfs.stop()
setIpfsReady(false)
ipfs = null
ipfsMessage = ''
ipfsVersion = ''
setIpfsError('')
console.timeEnd('IPFS stopped')
}
}
}, [])
return { ipfs, ipfsVersion, isIpfsReady, ipfsError, ipfsMessage }
}

View File

@ -0,0 +1,25 @@
@import '../../../../styles/variables';
.ipfsForm {
margin-top: $spacer / 2;
border: 1px solid $brand-grey-lighter;
border-radius: $border-radius;
padding: $spacer / 2;
background: $body-background;
input {
display: block;
width: 100%;
cursor: pointer;
border: .1rem solid $brand-grey-lighter;
border-radius: $border-radius;
padding: $spacer / 2 $spacer / 2;
margin-top: $spacer / 2;
background: $brand-white;
transition: border .2s ease-out;
&:hover {
border-color: $brand-grey-light;
}
}
}

View File

@ -0,0 +1,32 @@
import React from 'react'
import Label from '../../../../components/atoms/Form/Label'
import Status from './Status'
import styles from './Form.module.scss'
export default function Form({
children,
ipfsMessage,
ipfsError,
isIpfsReady,
error
}: {
children: any
ipfsMessage: string
ipfsError?: string
isIpfsReady: boolean
error?: string
}) {
return (
<div className={styles.ipfsForm}>
<Label htmlFor="fileUpload" required>
Add File To IPFS
</Label>
{children}
<Status
message={ipfsMessage}
isIpfsReady={isIpfsReady}
error={ipfsError || error}
/>
</div>
)
}

View File

@ -0,0 +1,36 @@
@import '../../../../styles/variables';
.message {
font-size: $font-size-small;
margin-top: $spacer / 2;
color: $brand-grey-light;
&:before {
content: '';
width: .5rem;
height: .5rem;
display: inline-block;
background: $yellow;
border-radius: 50%;
margin-right: $spacer / 6;
margin-bottom: .1rem;
}
}
.success {
composes: message;
&:before {
background: $green;
}
}
.error {
composes: message;
color: $red;
&:before {
border-radius: 0;
background: $red;
}
}

View File

@ -0,0 +1,19 @@
import React from 'react'
import styles from './Status.module.scss'
export default function Status({
message,
error,
isIpfsReady
}: {
message: string
error?: string
isIpfsReady: boolean
}) {
const classes = isIpfsReady
? styles.success
: error
? styles.error
: styles.message
return <div className={classes}>{error || message}</div>
}

View File

@ -0,0 +1,44 @@
import React from 'react'
import { render, fireEvent, waitForElement, act } from '@testing-library/react'
import Ipfs from '.'
const addFile = jest.fn()
describe('IPFS', () => {
const ui = <Ipfs addFile={addFile} />
const file = new File(['(⌐□_□)'], 'chucknorris.png', {
type: 'image/png'
})
it('HTTP API: files can be dropped', async () => {
const { container, findByText, getByText } = render(ui)
expect(container).toBeInTheDocument()
// wait for IPFS node
await findByText(/Connected to /)
// drop a file
const dropzoneInput = container.querySelector('.dropzone')
Object.defineProperty(dropzoneInput, 'files', { value: [file] })
act(() => {
dropzoneInput && fireEvent.drop(dropzoneInput)
})
const addingText = await waitForElement(() => getByText(/Adding /))
expect(addingText).toBeDefined()
})
// it('Local Node: files can be dropped', async () => {
// const { debug, container, findByText, getByText } = render(
// <Ipfs addFile={addFile} node />
// )
// expect(container).toBeInTheDocument()
// // wait for IPFS node
// await findByText(/IPFS started/)
// // drop a file
// const dropzoneInput = container.querySelector('.dropzone input')
// Object.defineProperty(dropzoneInput, 'files', { value: [file] })
// dropzoneInput && fireEvent.drop(dropzoneInput)
// await waitForElement(() => getByText(/File found/), { timeout: 100000 })
// expect(addFile).toHaveBeenCalledTimes(1)
// })
})

View File

@ -0,0 +1,102 @@
/* eslint-disable no-console */
import React, { useState, useEffect } from 'react'
import useIpfsApi, { IpfsConfig } from '../../../../hooks/use-ipfs-api'
import Spinner from '../../../../components/atoms/Spinner'
import Dropzone from '../../../../components/molecules/Dropzone'
import { formatBytes, pingUrl, readFileAsync } from '../../../../utils/utils'
import { ipfsGatewayUri, ipfsNodeUri } from '../../../../config'
import Form from './Form'
export default function Ipfs({ addFile }: { addFile(url: string): void }) {
const { hostname, port, protocol } = new URL(ipfsNodeUri)
const ipfsConfig: IpfsConfig = {
protocol: protocol.replace(':', ''),
host: hostname,
port: port || '443'
}
const { ipfs, isIpfsReady, ipfsError, ipfsMessage } = useIpfsApi(ipfsConfig)
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [fileSize, setFileSize] = useState('')
const [fileSizeReceived, setFileSizeReceived] = useState('')
const [error, setError] = useState('')
useEffect(() => {
setMessage(
`Adding to IPFS<br />
<small>${fileSizeReceived || 0}/${fileSize}</small><br />`
)
}, [fileSize, fileSizeReceived])
async function addToIpfs(data: any) {
try {
const response = await ipfs.add(data, {
wrapWithDirectory: true,
progress: (length: number) =>
setFileSizeReceived(formatBytes(length, 0))
})
// CID of wrapping directory is returned last
const cid = response[response.length - 1].hash
console.log(`File added: ${cid}`)
return cid
} catch (error) {
setError(`Adding to IPFS failed: ${error.message}`)
setLoading(false)
}
}
async function handleOnDrop(acceptedFiles: any) {
if (!acceptedFiles[0]) return
setLoading(true)
setError('')
const { path, size } = acceptedFiles[0]
const totalSize = formatBytes(size, 0)
setFileSize(totalSize)
// Add file to IPFS node
const content: any = await readFileAsync(acceptedFiles[0])
const data = Buffer.from(content)
const fileDetails = {
path,
content: data
}
const cid = await addToIpfs(fileDetails)
if (!cid) return
// Ping gateway url to make it globally available,
// but store native url in DDO.
const urlGateway = `${ipfsGatewayUri}/ipfs/${cid}/${path}`
const url = `ipfs://${cid}/${path}`
setMessage('Checking IPFS gateway URL')
const isAvailable = await pingUrl(urlGateway)
// add IPFS url to file.url
isAvailable && addFile(url)
}
return (
<Form
error={error}
ipfsMessage={ipfsMessage}
ipfsError={ipfsError}
isIpfsReady={isIpfsReady}
>
{loading ? (
<Spinner message={message} />
) : (
<Dropzone
multiple={false}
handleOnDrop={handleOnDrop}
disabled={!isIpfsReady}
/>
)}
</Form>
)
}

View File

@ -4,8 +4,9 @@
font-size: $font-size-small;
display: block;
margin-bottom: $spacer / 8;
// TODO: truncate long urls with ellipsis
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-all;
}
.remove {

View File

@ -5,7 +5,7 @@ import Dotdotdot from 'react-dotdotdot'
const Item = ({
item,
removeItem
removeFile
}: {
item: {
url: string
@ -13,7 +13,7 @@ const Item = ({
contentType: string
contentLength: number
}
removeItem(): void
removeFile(): void
}) => (
<li>
<a href={item.url} className={styles.linkUrl} title={item.url}>
@ -36,7 +36,7 @@ const Item = ({
type="button"
className={styles.remove}
title="Remove item"
onClick={removeItem}
onClick={removeFile}
>
&times;
</button>

View File

@ -2,7 +2,10 @@
.itemForm {
margin-top: $spacer / 2;
padding-left: $spacer / 2;
border: 1px solid $brand-grey-lighter;
border-radius: $border-radius;
padding: $spacer / 2;
background: $body-background;
button {
margin-top: -($spacer * 2);

View File

@ -2,10 +2,10 @@ import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import ItemForm from './ItemForm'
const addItem = jest.fn()
const addFile = jest.fn()
const setup = () => {
const utils = render(<ItemForm placeholder="Hello" addItem={addItem} />)
const utils = render(<ItemForm placeholder="Hello" addFile={addFile} />)
const input = utils.getByPlaceholderText('Hello')
const button = utils.getByText('Add File')
const { container } = utils
@ -23,17 +23,17 @@ describe('ItemForm', () => {
expect(container.firstChild).toBeInTheDocument()
})
it('fires addItem', async () => {
it('fires addFile', async () => {
const { input, button } = setup()
fireEvent.change(input, {
target: { value: 'https://hello.com' }
})
fireEvent.click(button)
expect(addItem).toHaveBeenCalled()
expect(addFile).toHaveBeenCalled()
})
it('does not fire addItem when no url present', () => {
it('does not fire addFile when no url present', () => {
const { input, button, container } = setup()
// empty url

View File

@ -1,11 +1,11 @@
import React, { PureComponent } from 'react'
import isUrl from 'is-url'
import isUrl from 'is-url-superb'
import Input from '../../../components/atoms/Form/Input'
import Button from '../../../components/atoms/Button'
import styles from './ItemForm.module.scss'
interface ItemFormProps {
addItem(url: string): void
addFile(url: string): void
placeholder: string
}
@ -37,12 +37,12 @@ export default class ItemForm extends PureComponent<
return
}
if (url && !isUrl(url)) {
if (url && !url.includes('ipfs://') && !isUrl(url)) {
this.setState({ noUrl: true })
return
}
this.props.addItem(url)
this.props.addFile(url)
}
private onChangeUrl = (e: React.FormEvent<HTMLInputElement>) => {
@ -68,6 +68,7 @@ export default class ItemForm extends PureComponent<
placeholder={this.props.placeholder}
value={url}
onChange={this.onChangeUrl}
help="Supported protocols are http(s):// and ipfs://"
/>
<Button onClick={(e: Event) => this.handleSubmit(e)}>

View File

@ -2,6 +2,10 @@
.newItems {
margin-top: $spacer / 2;
> button {
margin-right: $spacer;
}
}
.itemsList {

View File

@ -56,14 +56,28 @@ describe('Files', () => {
const { container, getByText } = renderComponent()
// open
fireEvent.click(getByText('+ Add a file'))
fireEvent.click(getByText('+ From URL'))
await waitForElement(() => getByText('- Cancel'))
expect(container.querySelector('.itemForm')).toBeInTheDocument()
// close
fireEvent.click(getByText('- Cancel'))
await waitForElement(() => getByText('+ Add a file'))
expect(container.querySelector('.grow-exit')).toBeInTheDocument()
await waitForElement(() => getByText('+ From URL'))
expect(container.querySelector('.itemForm')).not.toBeInTheDocument()
})
it('new IPFS file form can be opened and closed', async () => {
const { container, getByText } = renderComponent()
// open
fireEvent.click(getByText('+ Add to IPFS'))
await waitForElement(() => getByText('- Cancel'))
expect(container.querySelector('.ipfsForm')).toBeInTheDocument()
// close
fireEvent.click(getByText('- Cancel'))
await waitForElement(() => getByText('+ Add to IPFS'))
expect(container.querySelector('.ipfsForm')).not.toBeInTheDocument()
})
it('item can be removed', async () => {
@ -76,7 +90,7 @@ describe('Files', () => {
it('item can be added', async () => {
const { getByText, getByPlaceholderText } = renderComponent()
fireEvent.click(getByText('+ Add a file'))
fireEvent.click(getByText('+ From URL'))
await waitForElement(() => getByText('- Cancel'))
fireEvent.change(getByPlaceholderText('Hello'), {
target: { value: 'https://hello.com' }

View File

@ -1,25 +1,18 @@
import React, { FormEvent, PureComponent, ChangeEvent } from 'react'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import axios from 'axios'
import { Logger, File } from '@oceanprotocol/squid'
import shortid from 'shortid'
import Button from '../../../components/atoms/Button'
import Help from '../../../components/atoms/Form/Help'
import ItemForm from './ItemForm'
import Item from './Item'
import Ipfs from './Ipfs'
import styles from './index.module.scss'
import { serviceUri } from '../../../config'
import cleanupContentType from '../../../utils/cleanupContentType'
import { Logger } from '@oceanprotocol/squid'
export interface File {
url: string
contentType: string
checksum?: string
checksumType?: string
contentLength?: number
resourceId?: string
encoding?: string
compression?: string
export interface FilePublish extends File {
found: boolean // non-standard
}
@ -39,11 +32,26 @@ interface FilesProps {
interface FilesStates {
isFormShown: boolean
isIpfsFormShown: boolean
}
const buttons = [
{
id: 'url',
title: '+ From URL',
titleActive: '- Cancel'
},
{
id: 'ipfs',
title: '+ Add to IPFS',
titleActive: '- Cancel'
}
]
export default class Files extends PureComponent<FilesProps, FilesStates> {
public state: FilesStates = {
isFormShown: false
isFormShown: false,
isIpfsFormShown: false
}
// for canceling axios requests
@ -53,15 +61,19 @@ export default class Files extends PureComponent<FilesProps, FilesStates> {
this.signal.cancel()
}
private toggleForm = (e: Event) => {
private toggleForm = (e: Event, form: string) => {
e.preventDefault()
this.setState({ isFormShown: !this.state.isFormShown })
this.setState({
isFormShown: form === 'url' ? !this.state.isFormShown : false,
isIpfsFormShown:
form === 'ipfs' ? !this.state.isIpfsFormShown : false
})
}
private addItem = async (value: string) => {
const file: File = {
url: value,
private async getFile(url: string) {
const file: FilePublish = {
url,
contentType: '',
found: false // non-standard
}
@ -71,20 +83,42 @@ export default class Files extends PureComponent<FilesProps, FilesStates> {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
url: `${serviceUri}/api/v1/urlcheck`,
data: { url: value },
data: { url },
cancelToken: this.signal.token
})
const { contentLength, contentType, found } = response.data.result
file.contentLength = contentLength
file.contentType = contentType
file.compression = await cleanupContentType(contentType)
if (contentLength) file.contentLength = contentLength
if (contentType) {
file.contentType = contentType
file.compression = cleanupContentType(contentType)
}
file.found = found
return file
} catch (error) {
!axios.isCancel(error) && Logger.error(error.message)
}
}
private addFile = async (url: string) => {
// check for duplicate urls
const duplicateFiles = this.props.files.filter(props =>
url.includes(props.url)
)
if (duplicateFiles.length > 0) {
return this.setState({
isFormShown: false,
isIpfsFormShown: false
})
}
const file: FilePublish | undefined = await this.getFile(url)
file && this.props.files.push(file)
this.props.files.push(file)
const event = {
currentTarget: {
name: 'files',
@ -92,10 +126,16 @@ export default class Files extends PureComponent<FilesProps, FilesStates> {
}
}
this.props.onChange(event as any)
this.setState({ isFormShown: !this.state.isFormShown })
this.setState({
isFormShown: false,
isIpfsFormShown: false
})
this.forceUpdate()
}
private removeItem = (index: number) => {
private removeFile = (index: number) => {
this.props.files.splice(index, 1)
const event = {
currentTarget: {
@ -108,8 +148,8 @@ export default class Files extends PureComponent<FilesProps, FilesStates> {
}
public render() {
const { isFormShown } = this.state
const { files, help, placeholder, name, onChange } = this.props
const { isFormShown, isIpfsFormShown } = this.state
return (
<>
@ -126,43 +166,43 @@ export default class Files extends PureComponent<FilesProps, FilesStates> {
<div className={styles.newItems}>
{files.length > 0 && (
<TransitionGroup
component="ul"
className={styles.itemsList}
>
<ul className={styles.itemsList}>
{files.map((item: any, index: number) => (
<CSSTransition
key={index}
timeout={400}
classNames="fade"
>
<Item
item={item}
removeItem={() =>
this.removeItem(index)
}
/>
</CSSTransition>
<Item
key={shortid.generate()}
item={item}
removeFile={() => this.removeFile(index)}
/>
))}
</TransitionGroup>
</ul>
)}
<Button link onClick={this.toggleForm}>
{isFormShown ? '- Cancel' : '+ Add a file'}
</Button>
{buttons.map(button => {
const isActive =
(button.id === 'url' && isFormShown) ||
(button.id === 'ipfs' && isIpfsFormShown)
<CSSTransition
classNames="grow"
in={isFormShown}
timeout={200}
unmountOnExit
onExit={() => this.setState({ isFormShown: false })}
>
return (
<Button
key={shortid.generate()}
link
onClick={(e: Event) =>
this.toggleForm(e, button.id)
}
>
{isActive ? button.titleActive : button.title}
</Button>
)
})}
{isFormShown && (
<ItemForm
placeholder={placeholder}
addItem={this.addItem}
addFile={this.addFile}
/>
</CSSTransition>
)}
{isIpfsFormShown && <Ipfs addFile={this.addFile} />}
</div>
</>
)

View File

@ -32,12 +32,7 @@ describe('Publish', () => {
<MemoryRouter>
<Publish
history={history}
location={{
pathname: '/publish',
search: '',
hash: '',
state: ''
}}
location={location}
match={{ params: '', path: '', url: '', isExact: true }}
/>
</MemoryRouter>

View File

@ -1,5 +1,5 @@
import React, { ChangeEvent, Component, FormEvent } from 'react'
import { Logger } from '@oceanprotocol/squid'
import { Logger, File } from '@oceanprotocol/squid'
import Web3 from 'web3'
import Route from '../../components/templates/Route'
import Form from '../../components/atoms/Form/Form'
@ -11,7 +11,6 @@ import ReactGA from 'react-ga'
import { allowPricing } from '../../config'
import { steps } from '../../data/form-publish.json'
import Content from '../../components/atoms/Content'
import { File } from './Files'
import withTracker from '../../hoc/withTracker'
type AssetType = 'dataset' | 'algorithm' | 'container' | 'workflow' | 'other'

View File

@ -1,2 +1,21 @@
/* eslint-disable no-console */
import '@testing-library/jest-dom/extend-expect'
import '@testing-library/react/cleanup-after-each'
// this is just a little hack to silence a warning that we'll get until we
// upgrade to 16.9: https://github.com/facebook/react/pull/14853
const originalError = console.error
beforeAll(() => {
console.error = (...args) => {
if (/Warning.*not wrapped in act/.test(args[0])) {
return
}
originalError.call(console, ...args)
}
})
afterAll(() => {
console.error = originalError
})

View File

@ -0,0 +1,69 @@
import mockAxios from 'jest-mock-axios'
import { formatBytes, pingUrl, arraySum, readFileAsync } from './utils'
describe('formatBytes', () => {
it('outputs as expected', () => {
const number = 1024
const output = formatBytes(number, 0)
expect(output).toBe('1 KB')
})
it('0 conversion', () => {
const number = 0
const output = formatBytes(number, 0)
expect(output).toBe('0 Bytes')
})
})
describe('pingUrl', () => {
const mockResponse = {
status: 200,
data: {}
}
const mockResponseFaulty = {
status: 404,
statusText: 'Not Found',
data: {}
}
afterEach(() => {
mockAxios.reset()
})
it('pingUrl can be called', () => {
pingUrl('https://oceanprotocol.com')
mockAxios.mockResponse(mockResponse)
expect(mockAxios).toHaveBeenCalled()
})
it('pingUrl can be called with non 200 response', () => {
pingUrl('https://oceanprotocol.com')
mockAxios.mockResponse(mockResponseFaulty)
})
it('pingUrl error catch', () => {
pingUrl('https://oceanprotocol.com')
mockAxios.mockError({ message: 'Error catch' })
})
})
describe('arraySum', () => {
it('outputs as expected', () => {
const array = [2, 3]
const output = arraySum(array)
expect(output).toBe(5)
})
})
describe('readFileAsync', () => {
it('outputs as expected', async () => {
const file = new File(['ABC'], 'filename.txt', {
type: 'text/plain',
lastModified: Date.now()
})
const output = await readFileAsync(file)
expect(output).toBeInstanceOf(ArrayBuffer)
})
})

43
client/src/utils/utils.ts Normal file
View File

@ -0,0 +1,43 @@
/* eslint-disable no-console */
import axios from 'axios'
export function formatBytes(a: number, b: number) {
if (a === 0) return '0 Bytes'
const c = 1024
const d = b || 2
const e = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const f = Math.floor(Math.log(a) / Math.log(c))
return parseFloat((a / Math.pow(c, f)).toFixed(d)) + ' ' + e[f]
}
export function arraySum(array: number[]) {
return array.reduce((a, b) => a + b, 0)
}
export async function pingUrl(url: string) {
try {
const response = await axios(url)
if (response.status !== 200) console.error(`Not found: ${url}`)
console.log(`File found: ${url}`)
return true
} catch (error) {
console.error(error.message)
}
return false
}
export function readFileAsync(file: File) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => {
reader.abort()
reject(new DOMException('Problem parsing input file.'))
}
reader.onload = () => {
resolve(reader.result)
}
reader.readAsArrayBuffer(file)
})
}

View File

@ -15,5 +15,5 @@
"noEmit": true,
"jsx": "preserve"
},
"include": ["src", "config/config.ts"]
"include": ["src"]
}

View File

@ -1,6 +1,6 @@
/// <reference types="Cypress" />
context('Publish', () => {
before(() => {
describe('Publish', () => {
beforeEach(() => {
cy.visit('/publish')
cy.get('article>div', { timeout: 60000 }).should(
@ -9,12 +9,12 @@ context('Publish', () => {
)
})
it('Publish flow', () => {
it('should publish https:// file', () => {
// Fill title
cy.get('input#name').type('Commons Integration Test')
// Open Add a file form
cy.get('button')
.contains('+ Add a file')
.contains('+ From URL')
.click()
// Fill url of file
cy.get('input#url').type(
@ -76,4 +76,72 @@ context('Publish', () => {
)
})
})
it('should publish ipfs:// file', () => {
// Fill title
cy.get('input#name').type('Commons Integration IPFS Test')
// Open Add a file form
cy.get('button')
.contains('+ From URL')
.click()
// Fill url of file
cy.get('input#url').type(
'ipfs://QmX5LRpEVocfks9FNDnRoK2imf2fy9mPpP4wfgaDVXWfYD/video.mp4'
)
// Add file to main form
cy.get('button')
.contains('Add File')
.click()
// Verify and nove to next step
cy.get('button')
.contains('Next →')
.should('not.be.disabled')
.click()
// Verify we are on next step
cy.get('article>div').should('contain', 'Information')
// Fill description
cy.get('textarea#description').type('This is test description')
// Pick category
cy.get('select#categories').select('Biology')
// Verify and move to next step
cy.get('button')
.contains('Next →')
.should('not.be.disabled')
.click()
// Verify we are on next step
cy.get('article>div').should('contain', 'Authorship')
// Fill author
cy.get('input#author').type('Super Author')
// Fill copyright holder
cy.get('input#copyrightHolder').type('Super Copyright Holder')
// Pick author
cy.get('select#license').select('Public Domain')
// Verify and move to next step
cy.get('button')
.contains('Next →')
.should('not.be.disabled')
.click()
// Verify we are on next step
cy.get('article>div').should('contain', 'Register')
// Start publish process
cy.get('button')
.contains('Register asset')
.should('not.be.disabled')
.click()
// Wait for finish
cy.contains('Your asset is published!', {
timeout: 12000
}).should('be.visible')
// Store DID
cy.get('a')
.contains('See published asset')
.invoke('attr', 'href')
.then(href => {
cy.writeFile(
'cypress/fixtures/did-ipfs.txt',
href.replace('/asset/', '')
)
})
})
})

View File

@ -1,12 +1,12 @@
/// <reference types="Cypress" />
context('Search', () => {
describe('Search', () => {
before(() => {
cy.visit('/')
// Wait for end of loading
cy.get('button', { timeout: 60000 }).should('have.length', 1)
})
it('Search for assets from homepage', () => {
it('should search for assets from homepage', () => {
// Fill search phrase
cy.get('input#search').type('Title test')
// Start search

View File

@ -1,6 +1,6 @@
/// <reference types="Cypress" />
context('Consume', () => {
before(() => {
describe('Consume', () => {
beforeEach(() => {
cy.fixture('did').then(did => {
cy.visit(`/asset/${did}`)
})
@ -15,7 +15,38 @@ context('Consume', () => {
cy.get('button[name="Download"]').should('not.be.disabled')
})
it('Consume asset and check if there is no error', () => {
it('should consume https:// file', () => {
// eslint-disable-next-line
cy.wait(10000)
// Wait for faucet
// Click consume button
cy.get('button[name="Download"]').click()
// Wait consume process to end
cy.get('button[name="Download"]', { timeout: 600000 }).should(
'contain',
'Get file'
)
// check if there is no error
cy.get('article>div').should(
'not.contain',
'. Sorry about that, can you try again?'
)
// eslint-disable-next-line
cy.wait(10000)
// wait for file to download before closing browser
// to prevent alert poping up
})
it('should consume ipfs:// file', () => {
cy.fixture('did-ipfs').then(did => {
cy.visit(`/asset/${did}`)
})
// Alias button selector & wait for end of loading
cy.get('button[name="Download"]', { timeout: 60000 })
.first()
.should('have.length', 1)
// eslint-disable-next-line
cy.wait(10000)
// Wait for faucet

View File

@ -1,5 +1,5 @@
/// <reference types="Cypress" />
context('Faucet', () => {
describe('Faucet', () => {
before(() => {
cy.visit('/faucet')
// Wait for end of loading
@ -21,7 +21,7 @@ context('Faucet', () => {
.should('not.be.disabled')
})
it('Execute faucet call', () => {
it('should execute faucet call', () => {
// Execute call
cy.get('@button')
.contains('Request ETH')

64
package-lock.json generated
View File

@ -513,61 +513,69 @@
}
},
"@typescript-eslint/eslint-plugin": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-2.0.0.tgz",
"integrity": "sha512-Mo45nxTTELODdl7CgpZKJISvLb+Fu64OOO2ZFc2x8sYSnUpFrBUW3H+H/ZGYmEkfnL6VkdtOSxgdt+Av79j0sA==",
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-1.13.0.tgz",
"integrity": "sha512-WQHCozMnuNADiqMtsNzp96FNox5sOVpU8Xt4meaT4em8lOG1SrOv92/mUbEHQVh90sldKSfcOc/I0FOb/14G1g==",
"dev": true,
"requires": {
"@typescript-eslint/experimental-utils": "2.0.0",
"eslint-utils": "^1.4.0",
"@typescript-eslint/experimental-utils": "1.13.0",
"eslint-utils": "^1.3.1",
"functional-red-black-tree": "^1.0.1",
"regexpp": "^2.0.1",
"tsutils": "^3.14.0"
"tsutils": "^3.7.0"
}
},
"@typescript-eslint/experimental-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-2.0.0.tgz",
"integrity": "sha512-XGJG6GNBXIEx/mN4eTRypN/EUmsd0VhVGQ1AG+WTgdvjHl0G8vHhVBHrd/5oI6RRYBRnedNymSYWW1HAdivtmg==",
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-1.13.0.tgz",
"integrity": "sha512-zmpS6SyqG4ZF64ffaJ6uah6tWWWgZ8m+c54XXgwFtUv0jNz8aJAVx8chMCvnk7yl6xwn8d+d96+tWp7fXzTuDg==",
"dev": true,
"requires": {
"@types/json-schema": "^7.0.3",
"@typescript-eslint/typescript-estree": "2.0.0",
"@typescript-eslint/typescript-estree": "1.13.0",
"eslint-scope": "^4.0.0"
}
},
"@typescript-eslint/parser": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-2.0.0.tgz",
"integrity": "sha512-ibyMBMr0383ZKserIsp67+WnNVoM402HKkxqXGlxEZsXtnGGurbnY90pBO3e0nBUM7chEEOcxUhgw9aPq7fEBA==",
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-1.13.0.tgz",
"integrity": "sha512-ITMBs52PCPgLb2nGPoeT4iU3HdQZHcPaZVw+7CsFagRJHUhyeTgorEwHXhFf3e7Evzi8oujKNpHc8TONth8AdQ==",
"dev": true,
"requires": {
"@types/eslint-visitor-keys": "^1.0.0",
"@typescript-eslint/experimental-utils": "2.0.0",
"@typescript-eslint/typescript-estree": "2.0.0",
"@typescript-eslint/experimental-utils": "1.13.0",
"@typescript-eslint/typescript-estree": "1.13.0",
"eslint-visitor-keys": "^1.0.0"
}
},
"@typescript-eslint/typescript-estree": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-2.0.0.tgz",
"integrity": "sha512-NXbmzA3vWrSgavymlzMWNecgNOuiMMp62MO3kI7awZRLRcsA1QrYWo6q08m++uuAGVbXH/prZi2y1AWuhSu63w==",
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-1.13.0.tgz",
"integrity": "sha512-b5rCmd2e6DCC6tCTN9GSUAuxdYwCM/k/2wdjHGrIRGPSJotWMCe/dGpi66u42bhuh8q3QBzqM4TMA1GUUCJvdw==",
"dev": true,
"requires": {
"lodash.unescape": "4.0.1",
"semver": "^6.2.0"
"semver": "5.5.0"
},
"dependencies": {
"semver": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
"integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
"dev": true
}
}
},
"acorn": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.2.1.tgz",
"integrity": "sha512-JD0xT5FCRDNyjDda3Lrg/IxFscp9q4tiYtxE1/nOzlKCk7hIRuYjhq1kCNkbPjMRMZuFq20HNQn1I9k8Oj0E+Q==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-6.3.0.tgz",
"integrity": "sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA==",
"dev": true
},
"acorn-jsx": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.0.1.tgz",
"integrity": "sha512-HJ7CfNHrfJLlNTzIEUTj43LNWGkqpRLxm3YjAlcD0ACydk9XynzYsCBHxut+iqt+1aBXkx9UP/w/ZqMr13XIzg==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.0.2.tgz",
"integrity": "sha512-tiNTrP1MP0QrChmD2DdupCr6HWSFeKVw5d/dHTu4Y7rkAkRhU/Dt7dphAfIUyxtHpl/eBVip5uTNSpQJHylpAw==",
"dev": true
},
"ajv": {
@ -2118,9 +2126,9 @@
"dev": true
},
"semver": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz",
"integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==",
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"dev": true
},
"strip-ansi": {

View File

@ -25,8 +25,8 @@
"dependencies": {},
"devDependencies": {
"@release-it/bumper": "^1.0.3",
"@typescript-eslint/eslint-plugin": "^2.0.0",
"@typescript-eslint/parser": "^2.0.0",
"@typescript-eslint/eslint-plugin": "^1.13.0",
"@typescript-eslint/parser": "^1.13.0",
"auto-changelog": "^1.16.0",
"concurrently": "^4.1.2",
"cypress": "^3.4.1",

View File

@ -1 +1,2 @@
SENDGRID_API_KEY='xxx'
IPFS_GATEWAY_URI='https://ipfs.oceanprotocol.com'

View File

@ -4,12 +4,12 @@
This folder contains server component written in TypeScript using [Express](https://expressjs.com). The server provides various microservices.
- [Get Started](#Get-Started)
- [✨ API Documentation](#-API-Documentation)
- [Url Checker](#Url-Checker)
- [Report](#Report)
- [🎁 Contributing](#-Contributing)
- [🏛 License](#-License)
- [Get Started](#get-started)
- [✨ API Documentation](#-api-documentation)
- [Url Checker](#url-checker)
- [Report](#report)
- [🎁 Contributing](#-contributing)
- [🏛 License](#-license)
## Get Started
@ -36,6 +36,12 @@ Url Checker returns size and additional information about requested file. This s
}
```
```json
{
"url": "ipfs://QmQfpdcMWnLTXKKW9GPV7NgtEugghgD6HgzSF6gSrp2mL9"
}
```
**Response: Success**
```json

9
server/src/config.ts Normal file
View File

@ -0,0 +1,9 @@
import 'dotenv/config'
const config = {
app: { port: 4000 },
sendgridApiKey: process.env.SENDGRID_API_KEY,
ipfsGatewayUri: process.env.IPFS_GATEWAY_URI || 'https://gateway.ipfs.io'
}
export default config

View File

@ -1,7 +0,0 @@
const config = {
app: {
port: 4000
}
}
export default config

View File

@ -1,8 +1,8 @@
import { Router, Request, Response } from 'express'
import SendgridMail from '@sendgrid/mail'
import 'dotenv/config'
import config from '../config'
SendgridMail.setApiKey(process.env.SENDGRID_API_KEY)
SendgridMail.setApiKey(config.sendgridApiKey)
export class ReportRouter {
public router: Router

View File

@ -1,5 +1,6 @@
import { Router, Request, Response } from 'express'
import request from 'request'
import config from '../config'
export class UrlCheckRouter {
public router: Router
@ -12,44 +13,64 @@ export class UrlCheckRouter {
}
public checkUrl(req: Request, res: Response) {
if (!req.body.url) {
let { url } = req.body
if (!url) {
return res.send({ status: 'error', message: 'missing url' })
}
// map native IPFS URLs to gateway URLs
if (url.includes('ipfs://')) {
const cid = url.split('ipfs://')[1]
url = `${config.ipfsGatewayUri}/ipfs/${cid}`
}
request(
{
method: 'HEAD',
url: req.body.url,
url,
headers: { Range: 'bytes=0-' }
},
(error, response) => {
if (
response &&
(response.statusCode.toString().startsWith('2') ||
response.statusCode.toString().startsWith('416'))
) {
const { headers, statusCode } = response
const successResponses =
statusCode.toString().startsWith('2') ||
statusCode.toString().startsWith('416')
if (response && successResponses) {
const result: any = {}
result.found = true
if (response.headers['content-length']) {
if (headers['content-length']) {
result.contentLength = parseInt(
response.headers['content-length']
headers['content-length']
) // convert to number
}
if (response.headers['content-type']) {
const typeAndCharset = response.headers[
'content-type'
].split(';')
// sometimes servers send content-range header,
// try to use it if content-length is not present
if (
headers['content-range'] &&
!headers['content-length']
) {
const size = headers['content-range'].split('/')[1]
result.contentLength = parseInt(size) // convert to number
}
result.contentType = typeAndCharset[0] // eslint-disable-line prefer-destructuring
if (headers['content-type']) {
const typeAndCharset = headers['content-type'].split(
';'
)
/* eslint-disable prefer-destructuring */
result.contentType = typeAndCharset[0]
if (typeAndCharset[1]) {
/* eslint-disable prefer-destructuring */
result.contentCharset = typeAndCharset[1].split(
'='
)[1]
/* eslint-enable prefer-destructuring */
}
/* eslint-enable prefer-destructuring */
}
return res.send({ status: 'success', result })
}

View File

@ -10,7 +10,7 @@ import UrlCheckRouter from './routes/UrlCheckRouter'
import ReportRouter from './routes/ReportRouter'
// config
import config from './config/config'
import config from './config'
// debug
const log = debug('server:index')

View File

@ -13,9 +13,23 @@ describe('GET /', () => {
})
describe('POST /api/v1/urlcheck', () => {
it('responds with json', async () => {
const response = await request(server).post('/api/v1/urlcheck')
it('responds with json on http://', async () => {
const response = await request(server)
.post('/api/v1/urlcheck')
.send({ url: 'https://oceanprotocol.com/tech-whitepaper.pdf' })
expect(response.status).toBe(200)
expect(response.body).toBeTruthy()
})
it('responds with json on ipfs://', async () => {
const response = await request(server)
.post('/api/v1/urlcheck')
.send({
url:
'ipfs://QmX5LRpEVocfks9FNDnRoK2imf2fy9mPpP4wfgaDVXWfYD/video.mp4'
})
expect(response.status).toBe(200)
expect(response.body).toBeTruthy()
})
it('responds with error message when url is missing', async () => {
@ -26,6 +40,31 @@ describe('POST /api/v1/urlcheck', () => {
})
describe('POST /api/v1/report', () => {
const msg = {
to: 'test@example.com',
from: 'test@example.com',
subject: 'My Subject',
text: 'Text',
html: '<strong>HTML</strong>'
}
it('responds with json', async () => {
const response = await request(server)
.post('/api/v1/report')
.send({ msg })
expect(response.status).toBe(200)
expect(response.body).toBeTruthy()
})
it('responds with error', async () => {
const response = await request(server)
.post('/api/v1/report')
.send({ msg: 'Hello World' })
expect(response.text).toBe(
"undefined - Cannot create property 'isMultiple' on string 'Hello World'"
)
})
it('responds with error message when message is missing', async () => {
const response = await request(server).post('/api/v1/report')
const text = await JSON.parse(response.text)