private-dreamnet/dreamtime

View on GitHub
src/electron/src/modules/tools/fs.js

Summary

Maintainability
D
1 day
Test Coverage
import {
  attempt, startsWith, merge, endsWith, toNumber,
} from 'lodash'
import { join } from 'path'
import fs from 'fs-extra'
import { app, dialog } from 'electron'
import axios from 'axios'
import https from 'https'
import deferred from 'deferred'
import chokidar from 'chokidar'
import WebTorrent from 'webtorrent'
import IpfsCtl from 'ipfsd-ctl'
import toStream from 'it-to-stream'
import all from 'it-all'
import prettyBytes from 'pretty-bytes'
import URI from 'urijs'
import { EventEmitter } from 'events'
import { getAppResourcesPath, getPath } from './paths'

const logger = require('@dreamnet/logplease').create('electron:modules:tools:fs')

// eslint-disable-next-line node/no-deprecated-api
export * from 'fs-extra'

function getFilename(url) {
  const uri = new URI(url)

  if (uri.hasQuery('filename')) {
    return uri.query(true).filename
  }

  return uri.filename()
}

/**
 * @typedef DownloadOptions
 * @property {boolean} showSaveAs
 * @property {string} directory
 * @property {string} filename
 * @property {string} filepath
 */

/**
 * Returns the base64 of a dataURL
 * @param {*} dataURL
 */
export function getBase64Data(dataURL) {
  let encoded = dataURL.replace(/^data:(.*;base64,)?/, '')

  if (encoded.length % 4 > 0) {
    encoded += '='.repeat(4 - (encoded.length % 4))
  }

  return encoded
}

/**
 *
 * @param {string} path
 * @param {string} encoding
 */
export function read(path, encoding = 'utf-8') {
  return fs.readFileSync(path, { encoding })
}

/**
 *
 * @param {string} path
 * @param {string} dataURL
 */
export function writeDataURL(path, dataURL) {
  const data = this.getBase64Data(dataURL)
  return fs.outputFileSync(path, data, 'base64')
}

/**
 *
 * @param {string} path
 * @param {string} destinationPath
 */
export function extractZip(path, destinationPath) {
  const unzipper = require('unzipper')

  const def = deferred()

  const stream = fs.createReadStream(path).pipe(unzipper.Extract({ path: destinationPath }))

  stream.on('close', () => {
    def.resolve()
  })

  stream.on('error', (err) => {
    def.reject(err)
  })

  return def.promise
}

/**
 *
 * @param {string} path
 * @param {string} destinationPath
 */
export function extractSeven(path, destinationPath) {
  const { is, platform } = require('electron-util')
  const { extractFull } = require('node-7z')

  const def = deferred()

  let pathTo7zip

  if (is.development) {
    const sevenBin = require('7zip-bin')
    pathTo7zip = sevenBin.path7za
  } else {
    const binName = platform({
      macos: '7za',
      linux: '7za',
      windows: '7za.exe',
    })

    pathTo7zip = getAppResourcesPath('7zip-bin', binName)
  }

  const seven = extractFull(path, destinationPath, {
    $bin: pathTo7zip,
    recursive: true,
  })

  seven.on('end', () => {
    def.resolve()
  })

  seven.on('error', (err) => {
    def.reject(err)
  })

  return def.promise
}

/**
 *
 *
 * @export
 * @param {string} url
 * @param {*} options
 * @param {EventEmitter} events
 * @param {fs.WriteStream} writeStream
 */
export async function downloadFromHttp(url, options, events, writeStream) {
  let readStream
  let headers

  try {
    const request = await axios.request({
      url,
      responseType: 'stream',
      maxContentLength: -1,
      httpsAgent: new https.Agent({ rejectUnauthorized: false }),
    })

    readStream = request.data
    headers = request.headers
  } catch (err) {
    events.emit('error', err)
    return
  }

  // Close handler
  events.on('close', () => {
    attempt(() => {
      if (readStream) {
        readStream.destroy()
      }
    })
  })

  const contentLength = toNumber(headers['content-length'] || -1)

  readStream.on('error', (err) => {
    events.emit('error', err)
  })

  readStream.on('data', () => {
    events.emit('progress', {
      progress: contentLength > 0 ? toNumber(((writeStream.bytesWritten / contentLength) * 100).toFixed(2)) : -1,
      written: prettyBytes(writeStream.bytesWritten),
      total: contentLength > 0 ? prettyBytes(contentLength) : -1,
    })
  })

  readStream.pipe(writeStream)
}

/**
 *
 *
 * @export
 * @param {string} cid
 * @param {DownloadOptions} options
 * @param {EventEmitter} events
 * @param {fs.WriteStream} writeStream
 */
export async function downloadFromIPFS(cid, options, events, writeStream) {
  /** @type {import('ipfsd-ctl/src/ipfsd-daemon')} */
  let node
  let stats
  let readStream

  // Close handler
  events.on('close', () => {
    attempt(() => {
      if (readStream) {
        readStream.destroy()
      }

      if (node) {
        node.stop()
        node = null
      }
    })

    // Always make sure to delete this file.
    fs.removeSync(getPath('temp', 'dreamtime-ipfs', 'api'))
  })

  // Utility functions
  const createNode = async function () {
    const ipfsBin = require('go-ipfs').path().replace('app.asar', 'app.asar.unpacked')
    const ipfsRepo = getPath('temp', 'dreamtime-ipfs')

    logger.debug('Creating IPFS node...')
    logger.debug(`Bin: ${ipfsBin}`)
    logger.debug(`Repo: ${ipfsRepo}`)

    fs.ensureDirSync(ipfsRepo)

    node = await IpfsCtl.createController({
      ipfsHttpModule: require('ipfs-http-client'),
      ipfsBin,
      ipfsOptions: {
        repo: ipfsRepo,
      },
      remote: false,
      disposable: false,
    })

    await node.init()

    await node.start()

    if (!node.api) {
      logger.debug(node)
      throw new Error('The IPFS node was not created correctly.')
    }

    logger.debug('Created!')
  }

  const connectToProviders = async function () {
    logger.debug('Connecting to providers...')

    try {
      await node.api.swarm.connect('/dnsaddr/node1.dreamlink.cloud/tcp/4001/p2p/12D3KooWAuvHjmNSAxekkpqp9c5Hgcht7JJcZjQDjGUuLvYUDLPe')
      await node.api.swarm.connect('/dnsaddr/node2.dreamlink.cloud/tcp/4001/p2p/12D3KooWLSBENgc42uWwhsppUaRFknmSjcYvEyN5qLFtgr1PbEQS')
    } catch (err) {
      logger.warn(err)
    }
  }

  try {
    await createNode()

    await connectToProviders()

    logger.debug('Connected!')

    if (!node) {
      // It seems that the user has canceled it
      return
    }

    logger.debug(`Obtaining file ${cid} information...`)

    stats = await node.api.object.stat(cid, { timeout: 3 * 60 * 1000 })

    // eslint-disable-next-line no-console
    console.log(stats)

    logger.debug('Downloading...')

    readStream = toStream.readable(node.api.cat(cid))
  } catch (err) {
    events.emit('error', err)
    return
  }

  // eslint-disable-next-line promise/always-return
  all(node.api.dht.findProvs(cid, { timeout: 60 * 1000 })).then((provs) => {
    events.emit('peers', provs.length)
  }).catch(() => { })

  readStream.on('error', (err) => {
    events.emit('error', err)
  })

  readStream.on('data', () => {
    events.emit('progress', {
      progress: toNumber(((writeStream.bytesWritten / stats.CumulativeSize) * 100).toFixed(2)),
      written: prettyBytes(writeStream.bytesWritten),
      total: prettyBytes(stats.CumulativeSize),
    })
  })

  readStream.pipe(writeStream)
}

/**
 *
 *
 * @export
 * @param {string} magnetURI
 * @param {DownloadOptions} options
 * @param {EventEmitter} events
 * @param {fs.WriteStream} writeStream
 */
export function downloadFromTorrent(magnetURI, options, events, writeStream) {
  let client
  let torrent

  // Error handler
  events.on('close', () => {
    attempt(() => {
      if (torrent) {
        torrent.destroy()
      }

      if (client) {
        client.destroy()
      }
    })
  })

  try {
    client = new WebTorrent()

    torrent = client.add(magnetURI)
  } catch (err) {
    events.emit('error', err)
    return
  }

  const timeout = setTimeout(() => {
    events.emit('error', new Error('timeout'))
  }, 3000)

  client.on('error', (err) => {
    events.emit('error', err)
  })

  torrent.on('error', (err) => {
    events.emit('error', err)
  })

  torrent.on('metadata', () => {
    clearTimeout(timeout)

    if (torrent.files.length > 1) {
      events.emit('error', new Error('The torrent contains more than one file.'))
      return
    }

    events.emit('peers', torrent.numPeers)

    const file = torrent.files[0]

    file.createReadStream().pipe(writeStream)
  })

  torrent.on('download', () => {
    events.emit('progress', {
      progress: toNumber(torrent.progress),
      written: prettyBytes(torrent.downloaded),
      total: prettyBytes(torrent.length),
    })
  })

  torrent.on('wire', () => {
    events.emit('peers', torrent.numPeers)
  })
}

/**
 *
 * @param {string} url
 * @param {DownloadOptions} [options]
 */
export function download(url, options = {}) {
  const events = new EventEmitter()

  let cancelled = false

  // Options setup
  options = merge({
    showSaveAs: false,
    directory: app.getPath('downloads'),
    filename: getFilename(url),
  }, options)

  if (!options.filepath) {
    options.filepath = join(options.directory, options.filename)
  }

  if (options.showSaveAs) {
    options.filepath = dialog.showSaveDialogSync({
      defaultPath: options.filepath,
    })
  }

  // Write stream
  const writeStream = fs.createWriteStream(options.filepath)

  writeStream.on('error', (err) => {
    events.emit('error', err)
  })

  writeStream.on('finish', () => {
    if (cancelled) {
      events.emit('cancelled')
      return
    }

    // Finish & Close
    events.emit('finish', options.filepath)
    events.emit('close')
  })

  // Cancel handler
  events.on('cancel', () => {
    cancelled = true

    attempt(() => {
      fs.unlinkSync(options.filepath)
    })

    logger.info('Download cancelled by user.')

    // Cancelled & Close
    events.emit('cancelled')
    events.emit('close')
  })

  // Error handler
  events.on('error', (err) => {
    attempt(() => {
      fs.unlinkSync(options.filepath)
    })

    logger.warn('Download error:', err)

    // Error & Close
    events.emit('close')
  })

  // Close handler
  events.on('close', () => {
    attempt(() => {
      writeStream.destroy()
    })
  })

  // Download!
  if (startsWith(url, 'Qm')) {
    downloadFromIPFS(url, options, events, writeStream)
  } else if (startsWith(url, 'magnet:') || endsWith(url, '.torrent')) {
    downloadFromTorrent(url, options, events, writeStream)
  } else if (startsWith(url, 'http')) {
    downloadFromHttp(url, options, events, writeStream)
  } else {
    setTimeout(() => {
      events.emit('error', new Error('Invalid download address.'))
    }, 0)
  }

  return events
}

/**
 *
 * @param {string} url
 * @param {Object} [options]
 */
export function downloadAsync(url, options = {}) {
  return new Promise((resolve, reject) => {
    const bus = download(url, options)

    bus.on('finish', (filepath) => {
      resolve(filepath)
    })

    bus.on('error', (err) => {
      reject(err)
    })
  })
}

export { chokidar }