cozy-labs/cozy-desktop

View on GitHub
core/remote/index.js

Summary

Maintainability
D
2 days
Test Coverage
/** The remote side read/write interface.
 *
 * @module core/remote
 * @flow
 */

const autoBind = require('auto-bind')
const Promise = require('bluebird')
const path = require('path')
const async = require('async')

const { logger } = require('../utils/logger')
const { measureTime } = require('../utils/perfs')
const pathUtils = require('../utils/path')
const metadata = require('../metadata')
const { ROOT_DIR_ID, DIR_TYPE } = require('./constants')
const { RemoteCozy } = require('./cozy')
const {
  DirectoryNotFound,
  ExcludedDirError,
  isRetryableNetworkError
} = require('./errors')
const { RemoteWarningPoller } = require('./warning_poller')
const { RemoteWatcher } = require('./watcher')
const timestamp = require('../utils/timestamp')
const streamUtils = require('../utils/stream')

/*::
import type EventEmitter from 'events'
import type { SideName } from '../side'
import type { ProgressCallback, ReadableWithSize } from '../utils/stream'
import type { Config } from '../config'
import type {
  Metadata,
  MetadataRemoteDir,
  MetadataRemoteFile,
  MetadataRemoteInfo,
  SavedMetadata
} from '../metadata'
import type { Pouch } from '../pouch'
import type Prep from '../prep'
import type { RemoteDoc, RemoteFileVersion } from './document'
import type { Reader } from '../reader'
import type { Writer } from '../writer'

export type RemoteOptions = {
  config: Config,
  events: EventEmitter,
  pouch: Pouch,
  prep: Prep
}
*/

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

// A simplified version of the remote Root directory which will be used when
// looking for the parent directory's _id of documents at the root of the Cozy.
// The only information we care about are its _id, type and path.
const ROOT_DIR /*: MetadataRemoteDir */ = {
  _id: ROOT_DIR_ID,
  _rev: '1',
  dir_id: '',
  name: '',
  tags: [],
  created_at: '',
  updated_at: '',
  type: DIR_TYPE,
  path: '/'
}

/** `Remote` is the class that interfaces cozy-desktop with the remote Cozy.
 *
 * It uses a watcher, based on cozy-client-js, to poll for file and folder
 * changes from the remote CouchDB.
 * It also applies changes from the local filesystem on the remote cozy.
 *
 * Its `other` attribute is a reference to a {@link module:core/local|Local}
 * side instance.
 * This allows us to read from the local filesystem when writing to the remote
 * Cozy.
 */
class Remote /*:: implements Reader, Writer */ {
  /*::
  name: SideName
  other: Reader & Writer
  config: Config
  pouch: Pouch
  events: EventEmitter
  watcher: RemoteWatcher
  remoteCozy: RemoteCozy
  warningsPoller: RemoteWarningPoller
  */

  constructor({ config, prep, pouch, events } /*: RemoteOptions */) {
    this.name = 'remote'
    this.config = config
    this.pouch = pouch
    this.events = events
    this.remoteCozy = new RemoteCozy(config)
    this.warningsPoller = new RemoteWarningPoller(this.remoteCozy, events)
    this.watcher = new RemoteWatcher({
      config: this.config,
      pouch: this.pouch,
      events: this.events,
      remoteCozy: this.remoteCozy,
      prep
    })

    autoBind(this)
  }

  async start() {
    await this.watcher.start()
    return this.warningsPoller.start()
  }

  async stop() {
    await Promise.all([this.watcher.stop(), this.warningsPoller.stop()])
  }

  sendMail(args /*: any */) {
    return this.remoteCozy.createJob('sendmail', args)
  }

  unregister() {
    return this.remoteCozy.unregister()
  }

  update() {
    return this.remoteCozy.update()
  }

  updateLastSync() {
    return this.remoteCozy.updateLastSync()
  }

  /** Create a readable stream for the given doc */
  async createReadStreamAsync(
    doc /*: SavedMetadata */
  ) /*: Promise<ReadableWithSize> */ {
    const stream = await this.remoteCozy.downloadBinary(doc.remote._id)
    return streamUtils.withSize(stream, doc.size || 0)
  }

  /** Create a folder on the remote cozy instance */
  async addFolderAsync(doc /*: SavedMetadata */) /*: Promise<void> */ {
    const { path } = doc
    log.info('Creating folder...', { path })

    const [parentPath, name] = dirAndName(doc.path)
    const parent /*: RemoteDoc */ = await this.findDirectoryByPath(parentPath)

    try {
      const dir = await this.remoteCozy.createDirectory(
        newDocumentAttributes(name, parent._id, doc.updated_at)
      )
      metadata.updateRemote(doc, dir)
    } catch (err) {
      if (err.status === 409) {
        let remoteDoc
        try {
          remoteDoc = await this.findDocByPath(path)
        } catch (e) {
          log.warn('could not fetch conflicting directory', {
            path,
            err: e,
            originalErr: err
          })
        }
        if (remoteDoc && this.remoteCozy.isExcludedDirectory(remoteDoc)) {
          throw new ExcludedDirError(path)
        }
      }
      throw err
    }
  }

  async addFileAsync(
    doc /*: SavedMetadata */,
    onProgress /*: ?ProgressCallback */
  ) /*: Promise<void> */ {
    const { path } = doc
    log.info('Uploading new file...', { path })
    const stopMeasure = measureTime('RemoteWriter#addFile')

    const [parentPath, name] = dirAndName(path)
    const parent = await this.findDirectoryByPath(parentPath)

    await async.retry(
      { times: 5, interval: 2000, errorFilter: isRetryableNetworkError },
      async () => {
        let stream
        try {
          stream = await this.other.createReadStreamAsync(doc)
        } catch (err) {
          if (err.code === 'ENOENT') {
            log.warn('Local file does not exist anymore.', { path })
            // FIXME: with this deletion marker, the record will be erased from
            // PouchDB while the remote document will remain.
            doc.trashed = true
            return doc
          }
          throw err
        }

        const source = onProgress
          ? streamUtils.withProgress(stream, onProgress)
          : stream

        const created = await this.remoteCozy.createFile(source, {
          ...newDocumentAttributes(name, parent._id, doc.updated_at),
          checksum: doc.md5sum,
          executable: doc.executable || false,
          contentLength: doc.size,
          contentType: doc.mime
        })
        metadata.updateRemote(doc, created)
      }
    )

    stopMeasure()
  }

  async overwriteFileAsync(
    doc /*: SavedMetadata */,
    onProgress /*: ?ProgressCallback */
  ) /*: Promise<void> */ {
    const { path } = doc
    log.info('Uploading new file version...', { path })

    await async.retry(
      { times: 5, interval: 2000, errorFilter: isRetryableNetworkError },
      async () => {
        let stream
        try {
          stream = await this.other.createReadStreamAsync(doc)
        } catch (err) {
          if (err.code === 'ENOENT') {
            log.warn('Local file does not exist anymore.', { path })
            // FIXME: with this deletion marker, the record will be erased from
            // PouchDB while the remote document will remain.
            doc.trashed = true
            return doc
          }
          throw err
        }

        // Object.assign gives us the opportunity to enforce required options with
        // Flow while they're only optional in the Metadata type. For example,
        // `md5sum` and `mime` are optional in Metadata because they only apply to
        // files. But we're sure we have files at this point and that they do have
        // those attributes.
        const options = Object.assign(
          {},
          {
            checksum: doc.md5sum,
            executable: doc.executable || false,
            contentLength: doc.size,
            contentType: doc.mime,
            updatedAt: mostRecentUpdatedAt(doc),
            ifMatch: doc.remote._rev
          }
        )
        const source = onProgress
          ? streamUtils.withProgress(stream, onProgress)
          : stream

        const updated = await this.remoteCozy.updateFileById(
          doc.remote._id,
          source,
          options
        )
        metadata.updateRemote(doc, updated)
      }
    )
  }

  async updateFileMetadataAsync(doc /*: SavedMetadata */) /*: Promise<void> */ {
    const { path } = doc
    log.info('Updating file metadata...', { path })

    const attrs = {
      executable: doc.executable || false,
      updated_at: mostRecentUpdatedAt(doc)
    }
    const opts = {
      ifMatch: doc.remote._rev
    }
    const updated = await this.remoteCozy.updateAttributesById(
      doc.remote._id,
      attrs,
      opts
    )
    metadata.updateRemote(doc, updated)
  }

  async updateFolderAsync(doc /*: SavedMetadata */) /*: Promise<void> */ {
    const { path } = doc
    if (!doc.remote) {
      return this.addFolderAsync(doc)
    }
    log.info('Updating folder metadata...', { path })

    const attrs = {
      updated_at: mostRecentUpdatedAt(doc)
    }
    const opts = {
      ifMatch: doc.remote._rev
    }

    const newRemoteDoc = await this.remoteCozy.updateAttributesById(
      doc.remote._id,
      attrs,
      opts
    )
    metadata.updateRemote(doc, newRemoteDoc)
  }

  async moveAsync /*::<T: Metadata|SavedMetadata> */(
    newMetadata /*: T */,
    oldMetadata /*: T */
  ) /*: Promise<void> */ {
    const remoteId = oldMetadata.remote._id
    const { path, overwrite } = newMetadata
    const isOverwritingTarget =
      overwrite && overwrite.remote && overwrite.remote._id !== remoteId
    log.info(
      `Moving ${oldMetadata.docType}${
        isOverwritingTarget ? ' (with overwrite)' : ''
      }`,
      { path, oldpath: oldMetadata.path }
    )

    const [newParentPath, newName] /*: [string, string] */ = dirAndName(path)
    const newParent /*: MetadataRemoteDir */ = await this.findDirectoryByPath(
      newParentPath
    )

    const attrs = {
      name: newName,
      dir_id: newParent._id,
      updated_at: mostRecentUpdatedAt(newMetadata)
    }
    const opts = {
      ifMatch: oldMetadata.remote._rev
    }

    if (overwrite && isOverwritingTarget) {
      await this.trashAsync(overwrite)
    }

    const newRemoteDoc = await this.remoteCozy.updateAttributesById(
      remoteId,
      attrs,
      opts
    )
    metadata.updateRemote(newMetadata, newRemoteDoc)

    if (overwrite && isOverwritingTarget) {
      try {
        const referencedBy = await this.remoteCozy.getReferencedBy(
          overwrite.remote._id
        )
        await this.remoteCozy.addReferencedBy(remoteId, referencedBy)
        await this.assignNewRemote(newMetadata)
      } catch (err) {
        if (err.status === 404) {
          log.warn(`Cannot fetch references of missing ${overwrite.docType}.`, {
            path
          })
          return
        }
        throw err
      }
    }
  }

  async trashAsync(doc /*: SavedMetadata */) /*: Promise<void> */ {
    const { path } = doc
    log.info('Moving to the trash...', { path })

    try {
      const newRemoteDoc = await this.remoteCozy.trashById(doc.remote._id, {
        ifMatch: doc.remote._rev
      })
      metadata.updateRemote(doc, newRemoteDoc)
    } catch (err) {
      if (err.status === 404) {
        log.warn(`Cannot trash remotely deleted ${doc.docType}.`, { path })
        return
      } else if (
        err.status === 400 &&
        err.reason &&
        err.reason.errors &&
        /already in the trash/.test(err.reason.errors[0].detail)
      ) {
        log.warn(`Not trashing already trashed ${doc.docType}.`, { path })
        return
      }
      throw err
    }
  }

  async assignNewRemote /*::<T: Metadata|SavedMetadata> */(
    doc /*: T */
  ) /*: Promise<void> */ {
    log.info('Assigning new remote...', { path: doc.path })
    const newRemoteDoc = await this.remoteCozy.find(doc.remote._id)
    metadata.updateRemote(doc, newRemoteDoc)
  }

  diskUsage() /*: Promise<*> */ {
    return this.remoteCozy.diskUsage()
  }

  async hasEnoughSpace(doc /*: SavedMetadata */) /*: Promise<boolean> */ {
    const { size = 0 } = doc
    return this.remoteCozy.hasEnoughSpace(size)
  }

  async ping() /*: Promise<boolean> */ {
    try {
      // FIXME: find better way to check if Cozy is reachable?
      await this.diskUsage()
      return true
    } catch (err) {
      log.warn('Could not reach remote Cozy', { err })
      return false
    }
  }

  async findDocByPath(fpath /*: string */) /*: Promise<?MetadataRemoteInfo> */ {
    const [parentPath, name] = dirAndName(fpath)
    const { _id: dirID } = await this.findDirectoryByPath(parentPath)

    const results = await this.remoteCozy.search({ dir_id: dirID, name })
    if (results.length > 0) return results[0]
  }

  async findDirectoryByPath(
    path /*: string */
  ) /*: Promise<MetadataRemoteDir> */ {
    if (path === '.') return ROOT_DIR

    // XXX: We use the synced path instead of the remote path here as the goal
    // is to find parent directories of documents during the synchronization of
    // their changes and the parent can have been moved or renamed on the local
    // filesystem and not on the remote Cozy yet.
    // For now, the synced path is updated whenever the local or remote paths
    // are changed but we'll need to review this when we start updating it only
    // after a move has been fully synchronzed.
    const dir = await this.pouch.bySyncedPath(pathUtils.remoteToLocal(path))
    if (!dir || dir.deleted || !dir.remote || dir.docType !== metadata.FOLDER) {
      throw new DirectoryNotFound(path, this.config.cozyUrl)
    }

    return dir.remote
  }

  // Resolve the conflict created by the changes stored in `newMetadata` by
  // renaming its remote version with a conflict suffix so `newMetadata` can be
  // saved separately in PouchDB.
  async resolveConflict /*::<T: Metadata|SavedMetadata> */(
    newMetadata /*: T & { remote: MetadataRemoteInfo } */
  ) /*: Promise<?T> */ {
    const conflict = metadata.createConflictingDoc(newMetadata)

    log.info('Resolving remote conflict', {
      path: conflict.path,
      oldpath: newMetadata.path
    })
    await this.moveAsync(conflict, newMetadata)

    return conflict
  }

  async includeInSync(doc /*: SavedMetadata */) /*: Promise<*> */ {
    const remoteDocs = await this.remoteCozy.search({ path: `/${doc.path}` })
    const remoteDoc = remoteDocs[0]
    if (!remoteDoc || remoteDoc.type !== DIR_TYPE) return

    await this.remoteCozy.includeInSync(remoteDoc)
  }

  // XXX: Careful: the current version of a remote file is not part of the old
  // versions so if the given content is the same as the current remote file
  // content, this method will return `false`.
  async fileContentWasVersioned(
    { md5sum, size } /*: { md5sum: string, size: number } */,
    remoteDoc /*: MetadataRemoteFile */
  ) /*: Promise<boolean> */ {
    const oldVersions = await this.remoteCozy.fetchOldFileVersions(remoteDoc)
    return oldVersions.some(
      version => version.md5sum === md5sum && Number(version.size) === size
    )
  }
}

/** Extract the remote parent path and leaf name from a local path */
function dirAndName(localPath /*: string */) /*: [string, string] */ {
  const dir = path.dirname(localPath).split(path.sep).join('/')
  const name = path.basename(localPath)
  return [dir, name]
}

function newDocumentAttributes(
  name /*: string */,
  dirID /*: string */,
  updatedAt /*: string */
) {
  return {
    name,
    dirID,
    // We force the creation date otherwise the stack will set it with the
    // current date and could possibly update the modification date to be
    // greater.
    createdAt: updatedAt,
    updatedAt
  }
}

function mostRecentUpdatedAt /*::<T: Metadata|SavedMetadata> */(
  doc /*: T */
) /*: string */ {
  let date = doc.updated_at

  const remoteCreationDate = doc.remote && doc.remote.created_at
  if (remoteCreationDate) {
    date = timestamp.maxDate(date, remoteCreationDate)
  }

  const remoteModificationDate = doc.remote && doc.remote.updated_at
  if (remoteModificationDate) {
    date = timestamp.maxDate(date, remoteModificationDate)
  }

  return date
}

module.exports = {
  Remote,
  dirAndName
}