Lurch web app user interface

source

dropbox-file-system.js


import { FileSystem } from './file-system.js'
import { makeAbsoluteURL, loadScript } from './utilities.js'

// Internal use only, Lurch app key for Dropbox
const APP_KEY = '7mfyk58haigi2c4'
// Internal use only, how long before the login popup times out and rejects
const LOGIN_TIMEOUT = 30000 // in ms
// Internal use only, URL to CDN with the Dropbox JavaScript SDK
const SDK_URL = 'https://unpkg.com/dropbox@10/dist/Dropbox-sdk.min.js'

// Dropbox API calls return file/folder metadata with this format:
// if entry['.tag'] == 'file', entry has:
//   name, id, size, path_lower, is_downloadable, and more
// if entry['.tag'] == 'folder', entry has:
//   same, but not is_downloadable
// if entry['.tag'] == 'deleted', entry has:
//   same, but not id
// if response.result.has_more, then call:
//   client.filesListFolderContinue( {
//     cursor : response.result.cursor } )

// An instance of the Dropbox SDK's "Dropbox" object, which contains all the
// methods for communicating with the Dropbox servers.  Documentation for how
// this object works is available online from the Dropbox developer site, at
// {@link https://www.dropbox.com/developers/documentation?_tk=pilot_lp&_ad=topbar1&_camp=docs
// this link}.
// 
// This module exports this object so that any other portion of the app code can
// get access to dropbox by just importing this one constant from this module.
const getClient = () => loadScript( SDK_URL ).then( () =>
    new Dropbox.Dropbox( { clientId : APP_KEY } ) )

// Internal use only; combine two paths like Python's path.join()
const makeAbsolutePath = ( path1, path2 ) => {
    // If path1 is empty or path2 is absolute, then just use path2.
    if ( /^\s*$/.test( path1 ) || path2.startsWith( '/' ) )
        return path2
    // Since path2 is relative, so we need to add it to the end of path1.
    // Easiest case: When path1 ends with a /.
    if ( path1.endsWith( '/' ) )
        return path1 + path2
    // Next easiest case: If path1 ends with "foo.bar" we assume that's a file
    // because of the extension, so we replace that with path2.
    const parts = path1.split( '/' )
    if ( parts[parts.length-1].includes( '.' ) ) {
        parts[parts.length-1] = path2
        return parts.join( '/' )
    }
    // Tricky case: path1 ends in what doesn't seem to be a file (no extension)
    // so we will guess that it is probably the path to a folder, and will thus
    // just glue path2 on with a slash in between.
    return path1 + '/' + path2
}

// Internal use only
// Launch a popup window in which the Dropbox authentication UI is displayed,
// and resolve/reject a promise when that process completes (or times out).
const doLoginProcess = () => new Promise( ( resolve, reject ) => {
    // The only way this rejects is through a timeout.
    const rejectionTimeout = setTimeout( () => reject( 'Timed out' ), LOGIN_TIMEOUT )
    // Ask Dropbox for an authentication URL that redirects back to the Dropbox
    // login page in this folder:
    getClient().then( client =>
        client.auth.getAuthenticationUrl(
            makeAbsoluteURL( 'dropbox-login.html' ),
            undefined,             // optional state to help defeat XSS attacks
            'code',                // auth type is PKCE (see below for details)
            'offline',             // this means we get a refresh token, too
            undefined,             // use scope settings from our app's Dropbox portal
            undefined,             // don't make any modifications to those settings
            true                   // the PKCE type should be Sha256
        )
        // Now use that URL to open a popup window that asks the user to log in to
        // their Dropbox, and redirect to our callback page when they do so.
        .then( authUrl => {
            // We are using PKCE (proof key for code exchange), because Dropbox docs
            // say that it is a secure method for apps like ours.  It uses a code
            // verifier, which we must store on the client for later verification of
            // the user's login info:
            window.sessionStorage.clear()
            window.sessionStorage.setItem( 'code_verifier', client.auth.codeVerifier )
            // Now open the popup for login:
            window.open( authUrl, 'DropboxAuth', 'width=600,height=600' )
            // When the user finishes logging in, Dropbox will redirect to our
            // dropbox-login.html callback page, which will send a message to THIS
            // page containing the access and refresh tokens.
            window.addEventListener( 'message', event => {
                // Only listen to messages from pages on this same server.
                if ( event.origin !== window.location.origin ) return
                // The key pieces of login info are access and refresh tokens.
                // We tell the Dropbox client about the access token, and we store
                // the refresh token so we can update the access token later:
                client.auth.setAccessToken( event.data.access_token )
                window.localStorage.setItem(
                    'refresh_token', event.data.refresh_token )
                // Say that we have succeeded, and prevent a failure from timeout:
                clearTimeout( rejectionTimeout )
                resolve( client )
            } )
        } ).catch( reject )
    ).catch( reject )
} )

// Internal use only
// Ensure that we have access to the user's Dropbox, either by asking them to
// log in, or by finding in the browser's local storage a refresh token that is
// still valid, so that the user doesn't have to log in again.
const ensureAccess = () => new Promise( ( resolve, reject ) => {
    // If we don't have a refresh token stored, then the user has not yet
    // logged in to Dropbox on this browser, so make them do that.
    const refreshToken = window.localStorage.getItem( 'refresh_token' )
    if ( !refreshToken )
        return doLoginProcess().then( resolve ).catch( reject )
    // We do have a refresh token, so try to use it to get refresh the access to
    // the user's Dropbox.  Only ask for a re-login if that attempt fails.
    getClient().then( client => {
        client.auth.setRefreshToken( refreshToken )
        client.auth.refreshAccessToken().then(
            () => resolve( client )
        ).catch( error => {
            console.log( 'User must log in again because there was an error '
                + 'refreshing the access token: ' + error )
            doLoginProcess().then( resolve ).catch( reject )
        } )
    } ).catch( reject )
} )

/**
 * A subclass of {@link FileSystem} that reads from/writes to the user's
 * Dropbox, but of course only if the user logs in and gives the app access to
 * do so.  It implements all the abstract methods of the parent class, but
 * specific to that one storage location.  The permissions set in the Dropbox
 * developer portal for the Lurch app are that it will use only the
 * `/Apps/Lurch/` subfolder of the user's Dropbox.  It is a Dropbox convention
 * that apps that don't need full access to the user's Dropbox use a subfolder
 * of the `/Apps/` folder, based on the name of the app in question, such as
 * `/Apps/Lurch/`.
 * 
 * Dropbox uses filenames and paths, but it also supports file IDs.  This first
 * implementation uses only filenames and paths, but support could be added
 * later for file IDs if needed.
 */
export class DropboxFileSystem extends FileSystem {

    static subclassName = FileSystem.registerSubclass(
        'Dropbox', DropboxFileSystem )

    /**
     * See the documentation of the {@link FileSystem#read read()} method in the
     * parent class for the definition of how this method must behave.  It
     * implements the requirements specified there for a file system based on
     * the user's Dropbox, as defined at {@link DropboxFileSystem
     * the documentation for this class}.
     * 
     * Any file object that does not have a `.filename` member (including the
     * case where the parameter is omitted) will be interpreted as a folder (and
     * if the `.path` member is omitted, it counts as the root).
     * 
     * @param {Object} fileObject - as documented in the {@link FileSystem}
     *   class
     * @returns {Promise} as documented in {@link FileSystem#read the abstract
     *   method of the parent class}
     */
    read ( fileObject ) {
        // error cases
        if ( !fileObject?.filename )
            return Promise.reject( new Error( 'Missing filename' ) )
        if ( fileObject.fileSystemName
          && fileObject.fileSystemName != this.getName() )
            throw new Error( `Wrong file system: ${fileObject.fileSystemName}` )
        // correct case
        return new Promise( ( resolve, reject ) => {
            const absolute = makeAbsolutePath(
                fileObject.path || '', fileObject.filename )
            ensureAccess().then( client => {
                client.filesDownload( { path : absolute } ).then( response => {
                    response.result.fileBlob.text().then( result => {
                        fileObject.contents = result
                        fileObject.fileSystemName = this.getName()
                        resolve( fileObject )
                    } ).catch( reject )
                } ).catch( reject )
            } ).catch( reject )
        } )
    }

    /**
     * See the documentation of the {@link FileSystem#write write()} method in
     * the parent class for the definition of how this method must behave.  This
     * implements the requirements specified there for a file system
     * representing the user's Dropbox, as defined at {@link DropboxFileSystem
     * the documentation for this class}.
     * 
     * @param {Object} fileObject - as documented in the {@link FileSystem}
     *   class
     * @returns {Promise} as documented in {@link FileSystem#write the abstract
     *   method of the parent class}
     */
    write ( fileObject ) {
        // Case 1: Invalid input of various types
        if ( !fileObject )
            throw new Error( 'File object required for saving' )
        if ( fileObject.fileSystemName
          && fileObject.fileSystemName != this.getName() )
            throw new Error( `Wrong file system: ${fileObject.fileSystemName}` )
        if ( !fileObject.hasOwnProperty( 'filename' ) )
            throw new Error( 'No filename provided' )
        if ( !fileObject.hasOwnProperty( 'contents' ) )
            throw new Error( 'No content to write' )
        // Case 2: Filename and contents provided, can save
        const absolute = makeAbsolutePath(
            fileObject.path || '/', fileObject.filename )
        return new Promise( ( resolve, reject ) => {
            ensureAccess().then( client => {
                const toUpload = {
                    path : absolute,
                    mode : 'overwrite',
                    contents : fileObject.contents
                }
                client.filesUpload( toUpload ).then( () => {
                    fileObject.fileSystemName = this.getName()
                    this.documentSaved( fileObject )
                    resolve( fileObject )
                } ).catch( reject )
            } )
        } )
    }

    /**
     * See the documentation of the {@link FileSystem#delete delete()} method in
     * the parent class for the definition of how this method must behave.  This
     * implements the requirements specified there for a file system representing
     * the user's Dropbox, as defined at {@link DropboxFileSystem the
     * documentation for this class}.
     * 
     * @param {Object} fileObject - as documented in the {@link FileSystem}
     *   class
     * @returns {Promise} as documented in {@link FileSystem#delete the abstract
     *   method of the parent class}
     */
    delete ( fileObject ) {
        // Case 1: Invalid input of various types
        if ( !fileObject?.filename )
            throw new Error( 'No filename provided' )
        if ( fileObject.fileSystemName
          && fileObject.fileSystemName != this.getName() )
            throw new Error( `Wrong file system: ${fileObject.fileSystemName}` )
        // Case 2: Name of file provided, try to delete it
        const absolute = makeAbsolutePath(
            fileObject.path || '', fileObject.filename )
        return new Promise( ( resolve, reject ) => {
            ensureAccess().then( client => {
                client.filesDelete( { path : absolute } ).then( () => {
                    resolve( fileObject )
                } ).catch( reject )
            } ).catch( reject )
        } )
    }

    /**
     * See the documentation of the {@link FileSystem#has has()} method in the
     * parent class for the definition of how this method must behave.  This
     * implements the requirements specified there for a file system representing
     * the user's Dropbox, as defined at {@link DropboxFileSystem the
     * documentation for this class}.
     * 
     * Because this is a flat file system, any file object with a nonempty path
     * member will not exist, regardless of its filename, and will therefore
     * result in a false value being resolved from the promise.
     * 
     * @param {Object} fileObject - as documented in the {@link FileSystem}
     *   class
     * @returns {Promise} as documented in {@link FileSystem#has the abstract
     *   method of the parent class}
     */
    has ( fileObject ) {
        // If the parameter is missing or for the wrong file system, stop there
        if ( !fileObject )
            throw new Error( 'Missing required file object argument' )
        if ( fileObject.fileSystemName != this.getName() )
            throw new Error( `Wrong file system: ${fileObject.fileSystemName}` )
        // Check to see if the file is present by asking for its metadata
        const absolute = makeAbsolutePath(
            fileObject.path || '/', fileObject.filename )
        return new Promise( ( resolve, reject ) => {
            ensureAccess().then( client => {
                console.log( absolute )
                try {
                    client.filesGetMetadata( { path : absolute } ).then( () => {
                        resolve( true )
                    } ).catch( () => {
                        resolve( false )
                    } )
                } catch ( e ) {
                    console.log( e )
                    resolve( false )
                }
            } ).catch( reject )
        } )
    }

    /**
     * See the documentation of the {@link FileSystem#list list()} method in the
     * parent class for the definition of how this method must behave.  It
     * implements the requirements specified there for a file system based on
     * the user's Dropbox, as defined at {@link DropboxFileSystem the
     * documentation for this class}.
     * 
     * @param {Object} fileObject - as documented in the {@link FileSystem}
     *   class
     * @returns {Promise} as documented in {@link FileSystem#list the abstract
     *   method of the parent class}
     */
    list ( fileObject ) {
        // If wrong filesystem, stop there
        if ( fileObject?.fileSystemName
          && fileObject.fileSystemName != this.getName() )
            throw new Error( `Wrong file system: ${fileObject.fileSystemName}` )
        // Otherwise, just use the fileObject's path, or default to '' if empty:
        const pathToList = fileObject?.path || ''
        return new Promise( ( resolve, reject ) => {
            ensureAccess().then( client => {
                // We will connect all the results here, over possibly >1 page
                // of responses from Dropbox
                const allResults = [ ]
                const addResults = results => allResults.push( ...results )
                // When all pages have been collected, we will call this to
                // resolve the promise with all their results:
                const finish = () => resolve( allResults.map( entry => {
                    if ( entry['.tag'] == 'file' ) {
                        const path = entry.path_lower.split( '/' )
                        path.pop()
                        return {
                            fileSystemName : this.getName(),
                            filename : entry.name,
                            UID : entry.id,
                            path : path.join( '/' ) + '/'
                        }
                    }
                    if ( entry['.tag'] == 'folder' ) return {
                        fileSystemName : this.getName(),
                        path : entry.path_lower
                    }
                    throw new Error( 'Unknown file object tag: ' + entry['.tag'] )
                } ) )
                // When one page of results comes in, we will call this to
                // decide whether we are done or not, and either ask for more
                // results or, if we've hit the last page, finish the process:
                const processResponse = response => {
                    addResults( response.result.entries )
                    if ( response.result.has_more ) {
                        client.filesListFolderContinue( {
                            cursor : response.result.cursor
                        } ).then( processResponse ).catch( reject )
                    } else {
                        finish()
                    }
                }
                // Use all the tools above to make the request to Dropbox:
                const request = {
                    path : pathToList,
                    recursive : false,
                    include_deleted : false
                }
                client.filesListFolder( request )
                .then( processResponse ).catch( reject )
            } ).catch( reject )
        } )
    }

}