cozy-labs/cozy-desktop

View on GitHub
core/app.js

Summary

Maintainability
C
1 day
Test Coverage
/** Entry point of the synchronization core.
 *
 * @module core/app
 * @flow
 */
const autoBind = require('auto-bind')
const fse = require('fs-extra')
const _ = require('lodash')
const os = require('os')
const path = require('path')
const url = require('url')
const uuid = require('uuid').v4
const https = require('https')
const { createGzip } = require('zlib')
const semver = require('semver')
const { rootCozyUrl } = require('cozy-client')

const pkg = require('../package.json')
const config = require('./config')
const { Pouch } = require('./pouch')
const { migrations, runMigrations } = require('./migrations')
const Ignore = require('./ignore')
const { Merge } = require('./merge')
const Prep = require('./prep')
const { Local } = require('./local')
const { Remote } = require('./remote')
const { Sync } = require('./sync')
const SyncState = require('./syncstate')
const Registration = require('./remote/registration')
const logger = require('./utils/logger')
const { LOG_FILE, LOG_FILENAME } = logger
const { sendToTrash } = require('./utils/fs')
const notes = require('./utils/notes')
const web = require('./utils/web')

/*::
import type EventEmitter from 'events'
import type { Config } from './config'
import type stream from 'stream'
import type { Metadata } from './metadata'

export type ClientInfo = {
  appVersion: string,
  configPath: string,
  configVersion: ?string,
  cozyUrl: string,
  deviceName: ?string,
  osType: string,
  osRelease: string,
  osArch: string,
  permissions: string[],
  syncPath: string
}
*/

const log = logger({
  component: 'App'
})

const SUPPORT_EMAIL =
  process.env.COZY_DESKTOP_SUPPORT_EMAIL || 'contact@cozycloud.cc'

// App is the entry point for the CLI and GUI.
// They both can do actions and be notified by events via an App instance.
class App {
  /*::
  lang: string
  basePath: string
  config: Config
  pouch: Pouch
  events: EventEmitter
  ignore: Ignore.Ignore
  merge: Merge
  prep: Prep
  local: Local
  remote: Remote
  sync: Sync
  */

  // basePath is the directory where the config and pouch are saved
  constructor(basePath /*: string */) {
    log.info(this.clientInfo(), 'constructor')
    this.lang = 'fr'
    if (basePath == null) {
      basePath = os.homedir()
    }
    basePath = path.resolve(basePath)
    this.basePath = path.join(basePath, '.cozy-desktop')
    this.config = config.load(this.basePath)
    this.pouch = new Pouch(this.config)
    this.events = new SyncState()

    autoBind(this)
  }

  // Parse the URL
  parseCozyUrl(cozyUrl /*: string */) {
    if (!cozyUrl.includes('://')) {
      if (!cozyUrl.includes('.')) {
        cozyUrl += '.mycozy.cloud'
      }
      cozyUrl = `https://${cozyUrl}`
    }
    return new url.URL(cozyUrl)
  }

  // Check that the cozyUrl is valid
  async checkCozyUrl(cozyUrl /*: string */) /*: Promise<string> */ {
    const parsed = this.parseCozyUrl(cozyUrl)
    const rootUrl = await rootCozyUrl(parsed)
    return rootUrl.origin
  }

  // Returns an object including the syncPath only when valid, or with an error
  // otherwise.
  checkSyncPath(syncPath /*: string */) {
    // We do not allow syncing the whole user home directory, the system users
    // directory or the whole system:
    // - It would probably to big regarding the current local events squashing
    //   implementation.
    // - It could conflict with another synchronization tool.
    // - Writing some third-party file with the corresponding app running could
    //   make it crash.
    // - Some files are device-specific and should not be synchronized anyway.
    //
    // We could exclude relevant files by default at some point, but it would
    // require many iterations to make it reliable.
    if ((os.homedir() + path.sep).startsWith(syncPath)) {
      return {
        syncPath,
        error: 'You cannot synchronize your whole system or personal folder'
      }
    }

    return { syncPath }
  }

  // Return a promise for registering a device on the remote cozy
  registerRemote(
    cozyUrl /*: string */,
    redirectURI /*: ?string */,
    onRegistered /*: ?Function */,
    deviceName /*: string */
  ) {
    const registration = new Registration(cozyUrl, this.config)
    return registration.process(pkg, redirectURI, onRegistered, deviceName)
  }

  // Save the config with all the informations for synchonization
  saveConfig(cozyUrl /*: string */, syncPath /*: string */) {
    fse.ensureDirSync(syncPath)
    this.config.cozyUrl = cozyUrl
    this.config.syncPath = syncPath
    this.config.persist()
    log.info(
      'The remote Cozy has properly been configured ' +
        'to work with current device.'
    )
  }

  // Register current device to remote Cozy and then save related informations
  // to the config file (used by CLI, not GUI)
  async addRemote(
    cozyUrl /*: string */,
    syncPath /*: string */,
    deviceName /*: string */
  ) {
    try {
      const registered = await this.registerRemote(
        cozyUrl,
        null,
        null,
        deviceName
      )
      log.info(`Device ${registered.deviceName} has been added to ${cozyUrl}`)
      this.saveConfig(cozyUrl, syncPath)
    } catch (err) {
      let parsed /*: Object */ = this.parseCozyUrl(cozyUrl)
      if (err === 'Bad credentials') {
        log.warn(
          { err },
          'The Cozy passphrase used for registration is incorrect'
        )
      } else if (err.code === 'ENOTFOUND') {
        log.warn(
          { err },
          `The DNS resolution for ${parsed.hostname} failed while registering the device.`
        )
      } else {
        log.error(
          { err, sentry: true },
          'An error occured while registering the device.'
        )
        if (parsed.protocol === 'http:') {
          log.warn('Did you try with an httpS URL?')
        }
      }
    }
  }

  // Unregister current device from remote Cozy and then remove remote from
  // the config file
  async removeRemote() {
    try {
      if (!this.remote) this.instanciate()

      try {
        await this.remote.unregister()
      } catch (err) {
        if (!err.status || err.status !== 404) throw err
      }

      await this.removeConfig()
      log.info('Current device properly removed from remote cozy.')
      return null
    } catch (err) {
      log.error(
        { err, sentry: true },
        'An error occured while unregistering the device.'
      )
      return err
    }
  }

  async removeConfig() {
    log.info('Removing config...')
    await this.pouch.db.destroy()
    for (const name of await fse.readdir(this.basePath)) {
      if (name.startsWith(LOG_FILENAME)) continue
      await fse.remove(path.join(this.basePath, name))
    }
    log.info('Config removed')
  }

  async uploadFileToSupport(
    incident /*: string */,
    name /*: string */,
    data /*: string|stream.Readable */
  ) {
    return new Promise((resolve, reject) => {
      const req = https.request(
        {
          method: 'PUT',
          hostname: 'desktop-upload.cozycloud.cc',
          path: `/${incident}/${name}`,
          headers: {
            'Content-Type': 'text/plain'
          }
        },
        res => {
          if (res.statusCode === 201) {
            resolve(null)
          } else {
            reject(new Error('Bad Status, expected 201, got ' + res.statusCode))
          }
        }
      )
      req.on('error', reject)

      if (typeof data === 'string') {
        req.write(data)
        req.end()
      } else {
        data.pipe(req)
      }
    })
  }

  // Send an issue by mail to the support
  async sendMailToSupport(content /*: string */) {
    const incidentID = uuid()
    const zipper = createGzip({
      // TODO tweak this values, low resources for now.
      memLevel: 7,
      level: 3
    })
    const logs = fse.createReadStream(LOG_FILE)

    let pouchdbTree /*: ?Metadata[] */
    try {
      pouchdbTree = await this.pouch.localTree()
    } catch (err) {
      log.error({ err, sentry: true }, 'FAILED TO FETCH LOCAL TREE')
    }

    const logsSent = Promise.all([
      this.uploadFileToSupport(incidentID, 'logs.gz', logs.pipe(zipper)),
      pouchdbTree
        ? this.uploadFileToSupport(
            incidentID,
            'pouchdtree.json',
            JSON.stringify(pouchdbTree)
          )
        : Promise.resolve()
    ]).catch(err => {
      log.error({ err, sentry: true }, 'FAILED TO SEND LOGS')
    })

    content =
      content +
      '\r\n\r\n-------- debug info --------\r\n' +
      _.map(this.clientInfo(), (v, k) => `${k}: ${v}`).join('\r\n') +
      '\r\n\r\n-------- log status --------\r\n' +
      `incidentID: ${incidentID}`

    const args = {
      mode: 'from',
      to: [{ name: 'Support', email: SUPPORT_EMAIL }],
      subject: 'Ask support for cozy-desktop',
      parts: [{ type: 'text/plain', body: content }]
    }
    const mailSent = this.remote.sendMail(args)

    return Promise.all([mailSent, logsSent])
  }

  /** Path to the file containing user-defined ignore rules */
  userIgnoreRules() /*: string */ {
    return path.join(this.config.syncPath, '.cozyignore')
  }

  // Instanciate some objects before sync
  instanciate() {
    this.ignore = Ignore.loadSync(this.userIgnoreRules())
    this.merge = new Merge(this.pouch)
    this.prep = new Prep(this.merge, this.ignore, this.config)
    this.local = this.merge.local = new Local({ ...this, sendToTrash })
    this.remote = this.merge.remote = new Remote(this)
    this.sync = new Sync(
      this.pouch,
      this.local,
      this.remote,
      this.ignore,
      this.events
    )
  }

  // Start the synchronization
  startSync() {
    return this.sync.start()
  }

  // Stop the synchronisation
  stopSync() /*: Promise<void> */ {
    if (this.sync) {
      return this.sync.stop()
    } else {
      return Promise.resolve()
    }
  }

  async setup() {
    const clientInfo = this.clientInfo()
    log.info(clientInfo, 'user config')

    if (!this.config.isValid()) {
      throw new config.InvalidConfigError()
    }

    let wasUpdated = clientInfo.configVersion !== clientInfo.appVersion
    if (wasUpdated) {
      try {
        this.config.version = clientInfo.appVersion
      } catch (err) {
        log.error(
          { err, clientInfo, sentry: true },
          'could not update config version after app update'
        )
        wasUpdated = false
      }

      // TODO: remove with flag WINDOWS_DATE_MIGRATION_FLAG
      try {
        if (
          semver.lt(
            clientInfo.configVersion,
            config.WINDOWS_DATE_MIGRATION_APP_VERSION
          )
        ) {
          this.config.setFlag(config.WINDOWS_DATE_MIGRATION_FLAG, true)
        }
      } catch (err) {
        log.error(
          { err, sentry: true },
          `could not set ${config.WINDOWS_DATE_MIGRATION_FLAG} flag`
        )
      }
    }

    this.instanciate()

    await this.pouch.addAllViews()
    await runMigrations(migrations, this)

    if (wasUpdated && this.remote) {
      try {
        this.remote.update()
      } catch (err) {
        log.error(
          { err, config: this.config, sentry: true },
          'could not update OAuth client after app update'
        )
      }
    }
  }

  clientInfo() /*: ClientInfo */ {
    const config = this.config || {}

    return {
      appVersion: pkg.version,
      configPath: config.configPath,
      configVersion: config.version,
      cozyUrl: config.cozyUrl,
      deviceName: config.deviceName,
      osType: os.type(),
      osRelease: os.release(),
      osArch: os.arch(),
      permissions: config.permissions,
      syncPath: config.syncPath
    }
  }

  // Get disk space informations from the cozy
  diskUsage() /*: Promise<*> */ {
    if (!this.remote) this.instanciate()
    return this.remote.diskUsage()
  }

  findNote(filePath /*: string */) {
    return notes.findNote(filePath, this)
  }

  findDocument(filePath /*: string */) {
    return web.findDocument(filePath, this)
  }
}

module.exports = {
  App,
  logger
}