cozy-labs/cozy-desktop

View on GitHub
core/remote/errors.js

Summary

Maintainability
F
3 days
Test Coverage
/**
 * @module core/remote/errors
 * @flow
 */

const { FILE_TYPE, MAX_FILE_SIZE } = require('./constants')

/*::
import type { SavedMetadata } from '../metadata'
import type { Warning } from './cozy'

import type { FetchError } from 'cozy-stack-client'
export type { FetchError }
*/

const CONFLICTING_NAME_CODE = 'ConflictingName'
const COZY_CLIENT_REVOKED_CODE = 'CozyClientRevoked'
const COZY_NOT_FOUND_CODE = 'CozyNotFound'
const DOCUMENT_IN_TRASH_CODE = 'DocumentInTrash'
const FILE_TOO_LARGE_CODE = 'FileTooLarge'
const INVALID_FOLDER_MOVE_CODE = 'InvalidFolderMove'
const INVALID_METADATA_CODE = 'InvalidMetadata'
const INVALID_NAME_CODE = 'InvalidName'
const MISSING_DOCUMENT_CODE = 'MissingDocument'
const MISSING_PARENT_CODE = 'MissingParent'
const MISSING_PERMISSIONS_CODE = 'MissingPermissions'
const NEEDS_REMOTE_MERGE_CODE = 'NeedsRemoteMerge'
const NO_COZY_SPACE_CODE = 'NoCozySpace'
const PATH_TOO_DEEP_CODE = 'PathTooDeep'
const REMOTE_MAINTENANCE_ERROR_CODE = 'RemoteMaintenance'
const UNKNOWN_INVALID_DATA_ERROR_CODE = 'UnknownInvalidDataError'
const UNKNOWN_REMOTE_ERROR_CODE = 'UnknownRemoteError'
const UNREACHABLE_COZY_CODE = 'UnreachableCozy'
const USER_ACTION_REQUIRED_CODE = 'UserActionRequired'

const COZY_CLIENT_REVOKED_MESSAGE = 'Cozy client has been revoked' // Only necessary for the GUI

class CozyDocumentMissingError extends Error {
  /*::
  cozyURL: string
  doc: { name: string }
  */

  constructor(
    { cozyURL, doc } /*: { cozyURL: string, doc: { name: string } } */
  ) {
    super('Could not find document on remote Cozy')

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, CozyDocumentMissingError)
    }

    this.name = 'CozyDocumentMissingError'
    this.cozyURL = cozyURL
    this.doc = doc
  }
}

class DirectoryNotFound extends Error {
  /*::
  path: string
  cozyURL: string
  */

  constructor(path /*: string */, cozyURL /*: string */) {
    super(`Directory ${path} was not found on Cozy ${cozyURL}`)

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, DirectoryNotFound)
    }

    this.name = 'DirectoryNotFound'
    this.path = path
    this.cozyURL = cozyURL
  }
}

class ExcludedDirError extends Error {
  /*::
  path: string
  */

  constructor(path /*: string */) {
    super(
      `Directory ${path} was excluded from the synchronization on this client`
    )

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, DirectoryNotFound)
    }

    this.name = 'ExcludedDirError'
    this.path = path
  }
}

class UnreachableError extends Error {
  /*::
  cozyURL: string
  */

  constructor({ cozyURL } /*: { cozyURL: string } */) {
    super('Cannot reach remote Cozy')

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, UnreachableError)
    }

    this.name = 'UnreachableError'
    this.cozyURL = cozyURL
  }
}

class RemoteError extends Error {
  /*::
  $key: string
  $value: any

  code: string
  originalErr: Error
  */

  static fromWarning(warning /*: Warning */) {
    return new RemoteError({
      code: USER_ACTION_REQUIRED_CODE,
      message: warning.title,
      extra: warning,
      err: new Error(warning)
    })
  }

  constructor(
    {
      code = UNKNOWN_REMOTE_ERROR_CODE,
      message,
      err,
      extra = {}
    } /*: { code?: string, message?: string, err: Error, extra?: Object } */
  ) {
    super(message)

    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, CozyDocumentMissingError)
    }

    // Copy over all attributes from original error. We copy them before setting
    // other attributes to make sure the specific RemoteError attributes are not
    // overwritten.
    for (const [attr, value] of Object.entries(err)) {
      Object.defineProperty(this, attr, {
        value,
        writable: true,
        enumerable: true
      })
    }

    // Copy over extra attributes. We copy them before setting other attributes
    // to make sure the specific RemoteError attributes are not overwritten.
    for (const [attr, value] of Object.entries(extra)) {
      Object.defineProperty(this, attr, {
        value,
        writable: true,
        enumerable: true
      })
    }

    this.name = 'RemoteError'
    this.code = code
    this.originalErr = err

    this.buildMessage()
  }

  // Sets error message to:
  //   | <Original error message>
  //   | <Code>: <Original error message>
  //   | <Custom message>: <Original error message>
  //   | <Code>: <Custom message>: <Original error message>
  buildMessage() {
    this.message =
      (this.code ? `[${this.code}]: ` : '') +
      (this.message && this.message !== '' ? `${this.message}: ` : '') +
      this.originalErr.message
  }
}

const wrapError = (
  err /*: FetchError |  Error */,
  doc /*: ?SavedMetadata */
) /*: RemoteError */ => {
  if (isNetworkError(err)) {
    // $FlowFixMe FetchErrors missing status will fallback to the default case
    const { status } = err

    switch (status) {
      case 400:
        if (detail(err) === 'File or directory is already in the trash') {
          return new RemoteError({
            code: DOCUMENT_IN_TRASH_CODE,
            message: 'Remote document is in the Cozy trash',
            err
          })
        } else {
          // TODO: Merge with ClientRevokedError
          return new RemoteError({
            code: COZY_CLIENT_REVOKED_CODE,
            message: COZY_CLIENT_REVOKED_MESSAGE, // We'll match the message to display an error in gui/main
            err
          })
        }
      case 402:
        try {
          const parsedMessage = JSON.parse(err.message)
          return new RemoteError({
            code: USER_ACTION_REQUIRED_CODE,
            message: parsedMessage.title,
            err,
            extra: parsedMessage[0] // cozy-stack returns error arrays
          })
        } catch (parseError) {
          return new RemoteError({ err })
        }
      case 403:
        return new RemoteError({
          code: MISSING_PERMISSIONS_CODE,
          message: 'Cozy client is missing permissions (lack disk-usage?)',
          err
        })
      case 404:
        if (hasNoReason(err)) {
          return new RemoteError({
            code: COZY_NOT_FOUND_CODE,
            message: 'Remote Cozy could not be found',
            err
          })
        } else {
          return new RemoteError({
            code: MISSING_DOCUMENT_CODE,
            message: 'The updated document is missing on the remote Cozy',
            err
          })
        }
      case 409:
        return new RemoteError({
          code: CONFLICTING_NAME_CODE,
          message:
            'A document with the same name already exists on the remote Cozy at the same location',
          err
        })
      case 412:
        if (sourceParameter(err) === 'If-Match') {
          // Revision error
          return new RemoteError({
            code: NEEDS_REMOTE_MERGE_CODE,
            message: 'The known remote document revision is outdated',
            err
          })
        } else if (sourceParameter(err) === 'dir-id') {
          // The directory is asked to move to one of its sub-directories
          return new RemoteError({
            code: INVALID_FOLDER_MOVE_CODE,
            message:
              'The folder would be moved wihtin one of its sub-folders on the remote Cozy',
            err
          })
        } else {
          // Invalid hash or content length error
          return new RemoteError({
            code: INVALID_METADATA_CODE,
            message: 'The local metadata for the document is corrupted',
            err
          })
        }
      case 413:
        if (isFileLargerThanAllowed(doc)) {
          return new RemoteError({
            code: FILE_TOO_LARGE_CODE,
            message: 'The file is larger than allowed by the remote Cozy',
            err
          })
        } else {
          return new RemoteError({
            code: NO_COZY_SPACE_CODE,
            message: 'Not enough space available on remote Cozy',
            err
          })
        }
      case 422:
        if (sourceParameter(err) === 'name') {
          return new RemoteError({
            code: INVALID_NAME_CODE,
            message:
              'The name of the document contains characters forbidden by the remote Cozy',
            err
          })
        } else if (sourceParameter(err) === 'path') {
          return new RemoteError({
            code: PATH_TOO_DEEP_CODE,
            message:
              'The path of the document has too many levels for the remote Cozy',
            err
          })
        } else {
          return new RemoteError({
            code: INVALID_METADATA_CODE,
            message: 'The local metadata for the document is corrupted',
            err
          })
        }
      default:
        if (status > 400 && status < 500) {
          return new RemoteError({
            code: UNKNOWN_INVALID_DATA_ERROR_CODE,
            message:
              'The data sent to the remote Cozy is invalid for some unhandled reason',
            err
          })
        } else if (status >= 500 && status < 600) {
          return new RemoteError({
            code: UNKNOWN_REMOTE_ERROR_CODE,
            message:
              'The remote Cozy failed to process the request for an unknown reason',
            err
          })
        } else {
          // TODO: Merge with UnreachableError?!
          return new RemoteError({
            code: UNREACHABLE_COZY_CODE,
            message: 'Cannot reach remote Cozy',
            err
          })
        }
    }
  } else if (err instanceof DirectoryNotFound) {
    return new RemoteError({
      code: MISSING_PARENT_CODE,
      message:
        'The parent directory of the document is missing on the remote Cozy',
      err
    })
  } else if (err instanceof RemoteError) {
    return err
  } else {
    return new RemoteError({ err })
  }
}

function sourceParameter(err /*: FetchError */) /*: ?string */ {
  const { errors } = err.reason || {}
  const { source } = (errors && errors[0]) || {}
  const { parameter } = source || {}
  return parameter
}

function hasNoReason(err /*: FetchError */) /*: boolean %checks */ {
  return (
    err.reason != null &&
    typeof err.reason === 'object' &&
    err.reason.error != null &&
    typeof err.reason.error === 'object' &&
    Object.keys(err.reason.error).length === 0
  )
}

function isFileLargerThanAllowed(
  doc /*: ?SavedMetadata */
) /*: boolean %checks */ {
  return (
    doc != null &&
    doc.docType === FILE_TYPE &&
    doc.size != null &&
    doc.size > MAX_FILE_SIZE
  )
}

function detail(err /*: FetchError */) /*: ?string */ {
  const { errors } = err.reason || {}
  const { detail } = (errors && errors[0]) || {}
  return detail
}

function isNetworkError(err /*: Error */) {
  return (
    err.name === 'FetchError' ||
    (typeof err.message === 'string' && err.message.includes('net::'))
  )
}

function isRetryableNetworkError(err /*: Error */) {
  return (
    typeof err.message === 'string' &&
    err.message.includes('net::') &&
    !err.message.includes('net::ERR_INTERNET_DISCONNECTED') &&
    !err.message.includes('net::ERR_PROXY_CONNECTION_FAILED')
  )
}

module.exports = {
  CozyDocumentMissingError,
  DirectoryNotFound,
  ExcludedDirError,
  RemoteError,
  UnreachableError,
  COZY_CLIENT_REVOKED_MESSAGE, // FIXME: should be removed once gui/main does not use it anymore
  CONFLICTING_NAME_CODE,
  COZY_CLIENT_REVOKED_CODE,
  COZY_NOT_FOUND_CODE,
  DOCUMENT_IN_TRASH_CODE,
  FILE_TOO_LARGE_CODE,
  INVALID_FOLDER_MOVE_CODE,
  INVALID_METADATA_CODE,
  INVALID_NAME_CODE,
  MISSING_DOCUMENT_CODE,
  MISSING_PARENT_CODE,
  MISSING_PERMISSIONS_CODE,
  NEEDS_REMOTE_MERGE_CODE,
  NO_COZY_SPACE_CODE,
  PATH_TOO_DEEP_CODE,
  REMOTE_MAINTENANCE_ERROR_CODE,
  UNKNOWN_INVALID_DATA_ERROR_CODE,
  UNKNOWN_REMOTE_ERROR_CODE,
  UNREACHABLE_COZY_CODE,
  USER_ACTION_REQUIRED_CODE,
  isNetworkError,
  isRetryableNetworkError,
  wrapError
}