200ok-ch/organice

View on GitHub
src/sync_backend_clients/dropbox_sync_backend_client.js

Summary

Maintainability
B
5 hrs
Test Coverage
/* global process */

import { isEmpty } from 'lodash';
import { orgFileExtensions } from '../lib/org_utils';

import { persistField, getPersistedField } from '../util/settings_persister';

import { Dropbox } from 'dropbox';

import parseQueryString from '../util/parse_query_string';

import { fromJS, Map } from 'immutable';

/**
 * Gets a directory listing ready to be rendered by organice.
 *  - Filters files from `listing` down to org files.
 *  - Sorts folders atop of files.
 *  - Sorts both folders and files alphabetically.
 * @param {Array} listing
 */
export const filterAndSortDirectoryListing = (listing) => {
  const filteredListing = listing.filter((file) => {
    // Show all folders
    if (file['.tag'] === 'folder') return true;
    // Filter out all non-org files
    return file.name.match(orgFileExtensions);
  });
  return filteredListing.sort((a, b) => {
    // Folders before files
    if (a['.tag'] === 'folder' && b['.tag'] === 'file') {
      return -1;
    } else {
      // Sorth both folders and files alphabetically
      return a.name > b.name ? 1 : -1;
    }
  });
};

function getCodeFromUrl() {
  return parseQueryString(window.location.search).code;
}

export default () => {
  let dbxPromise;

  const isSignedIn = () => new Promise((resolve) => resolve(true));

  const transformDirectoryListing = (listing) => {
    const sortedListing = filterAndSortDirectoryListing(listing);
    return fromJS(
      sortedListing.map((entry) => ({
        id: entry.id,
        name: entry.name,
        isDirectory: entry['.tag'] === 'folder',
        path: entry.path_display,
      }))
    );
  };

  const getDirectoryListing = (path) =>
    new Promise((resolve, reject) => {
      dbxPromise
        .then((dbx) => {
          dbx.filesListFolder({ path }).then((response) => {
            resolve({
              listing: transformDirectoryListing(response.result.entries),
              hasMore: response.result.has_more,
              additionalSyncBackendState: Map({
                cursor: response.result.cursor,
              }),
            });
          });
        })
        .catch(reject);
    });

  const getMoreDirectoryListing = (additionalSyncBackendState) => {
    const cursor = additionalSyncBackendState.get('cursor');
    return new Promise((resolve, reject) =>
      dbxPromise.then((dbx) => {
        dbx.filesListFolderContinue({ cursor }).then((response) =>
          resolve({
            listing: transformDirectoryListing(response.result.entries),
            hasMore: response.result.has_more,
            additionalSyncBackendState: Map({
              cursor: response.result.cursor,
            }),
          })
        );
      })
    );
  };

  const uploadFile = (path, contents) =>
    new Promise((resolve, reject) =>
      dbxPromise.then((dbx) => {
        dbx
          .filesUpload({
            path,
            contents,
            mode: {
              '.tag': 'overwrite',
            },
            autorename: true,
          })
          .then(resolve)
          .catch(reject);
      })
    );

  const updateFile = uploadFile;
  const createFile = uploadFile;

  const getFileContentsAndMetadata = (path) =>
    new Promise((resolve, reject) =>
      dbxPromise.then((dbx) => {
        dbx
          .filesDownload({ path })
          .then((response) => {
            const reader = new FileReader();
            reader.addEventListener('loadend', () =>
              resolve({
                contents: reader.result,
                lastModifiedAt: response.result.server_modified,
              })
            );
            reader.readAsText(response.result.fileBlob);
          })
          .catch((error) => {
            // INFO: It's possible organice is using the Dropbox API
            // wrongly. In any case, for some files and only sometimes,
            // when a file is requested, there's either:
            //   - a 400 with a plain text error or
            //   - a 409 with an embedded JSON error
            //   - a 409 with a plain text error under `.error`
            // coming back. Sometimes, there's even two API calls to
            // `/download` happening at the same time (of types `json`
            // and `octet-stream`) where one might fail and the other
            // might prevail.
            // More debug information in this issue:
            // https://github.com/200ok-ch/organice/issues/108
            const objectContainsTagErrorP = (function () {
              try {
                return JSON.parse(error.error).error.path['.tag'] === 'not_found';
              } catch (e) {
                return false;
              }
            })();
            if (
              (typeof error === 'string' && error.match(/missing required field 'path'/)) ||
              objectContainsTagErrorP
            ) {
              reject();
            }
          });
      })
    );

  const getFileContents = (path) => {
    if (isEmpty(path)) return Promise.reject('No path given');
    return new Promise((resolve, reject) =>
      getFileContentsAndMetadata(path)
        .then(({ contents }) => resolve(contents))
        .catch(reject)
    );
  };

  const deleteFile = (path) =>
    new Promise((resolve, reject) =>
      dbxPromise.then((dbx) => {
        dbx
          .filesDelete({ path })
          .then(resolve)
          .catch((error) => reject(error.error.error['.tag'] === 'path_lookup', error));
      })
    );

  /* Dropbox documentation on OAuth2 and PKCE:

  -  SDK Repo: https://github.com/dropbox/dropbox-sdk-js
  -  OAuth Guide: https://developers.dropbox.com/oauth-guide
  -  PKCE: What and Why?: https://dropbox.tech/developers/pkce--what-and-why-
  -  Single HTML file example: https://github.com/dropbox/dropbox-sdk-js/blob/main/examples/javascript/pkce-browser/index.html
  -  SDK Docs: https://dropbox.github.io/dropbox-sdk-js/index.html
  -  Migrating App Permissions and Access Tokens: https://dropbox.tech/developers/migrating-app-permissions-and-access-tokens */

  const REDIRECT_URI = window.location.origin + '/';

  dbxPromise = new Promise((resolve, reject) => {
    const dbx = new Dropbox({
      clientId: process.env.REACT_APP_DROPBOX_CLIENT_ID,
      fetch: fetch.bind(window),
    });
    const dbxAuth = dbx.auth;

    if (getCodeFromUrl()) {
      dbxAuth.setCodeVerifier(getPersistedField('codeVerifier'));
      dbxAuth
        .getAccessTokenFromCode(REDIRECT_URI, getCodeFromUrl())
        .then((response) => {
          dbxAuth.setRefreshToken(response.result.refresh_token);
          persistField('dropboxRefreshToken', response.result.refresh_token);

          resolve(dbx);
        })
        .catch((error) => {
          console.error(error);
        });
    } else {
      dbxAuth.setCodeVerifier(getPersistedField('codeVerifier'));
      dbxAuth.setRefreshToken(getPersistedField('dropboxRefreshToken'));
      resolve(dbx);
    }
  });

  return {
    type: 'Dropbox',
    isSignedIn,
    getDirectoryListing,
    getMoreDirectoryListing,
    updateFile,
    createFile,
    getFileContentsAndMetadata,
    getFileContents,
    deleteFile,
  };
};