import { Dropbox , DropboxAuth , files } from "dropbox" ; import { Vault } from "obsidian" ; import * as path from "path" ; import { DropboxConfig , RemoteItem , COMMAND_CALLBACK_DROPBOX , OAUTH2_FORCE_EXPIRE_MILLISECONDS , } from "./baseTypes" ; import { decryptArrayBuffer , encryptArrayBuffer } from "./encrypt" ; import { bufferToArrayBuffer , getFolderLevels , mkdirpInVault } from "./misc" ; export { Dropbox } from "dropbox" ; import * as origLog from "loglevel" ; const log = origLog . getLogger ( "rs-default" ) ; export const DEFAULT_DROPBOX_CONFIG = { accessToken : "" , clientID : process . env . DEFAULT_DROPBOX_APP_KEY , refreshToken : "" , accessTokenExpiresInSeconds : 0 , accessTokenExpiresAtTime : 0 , accountID : "" , username : "" , credentialsShouldBeDeletedAtTime : 0 , } ; export const getDropboxPath = ( fileOrFolderPath , vaultName ) => { let key = fileOrFolderPath ; if ( fileOrFolderPath === "/" || fileOrFolderPath === "" ) { key = `/${ vaultName }` ; } if ( ! fileOrFolderPath . startsWith ( "/" ) ) { key = `/${ vaultName }/${ fileOrFolderPath }` ; } if ( key . endsWith ( "/" ) ) { key = key . slice ( 0 , key . length - 1 ) ; } return key ; } ; const getNormPath = ( fileOrFolderPath , vaultName ) => { if ( ! ( fileOrFolderPath === `/${ vaultName }` || fileOrFolderPath . startsWith ( `/${ vaultName }/` ) ) ) { throw Error ( `"${ fileOrFolderPath }" doesn't starts with "/${ vaultName }/"` ) ; } return fileOrFolderPath . slice ( `/${ vaultName }/` . length ) ; } ; const fromDropboxItemToRemoteItem = ( x , vaultName ) : RemoteItem => { let key = getNormPath ( x . path_display , vaultName ) ; if ( x [ ".tag" ] === "folder" && ! key . endsWith ( "/" ) ) { key = `${ key }/` ; } if ( x [ ".tag" ] === "folder" ) { return { key : key , lastModified : undefined , size : 0 , remoteType : "dropbox" , etag : `${ x . id }\t` , } as RemoteItem ; } else if ( x [ ".tag" ] === "file" ) { return { key : key , lastModified : Date . parse ( x . server_modified ) . valueOf ( ) , size : x . size , remoteType : "dropbox" , etag : `${ x . id }\t${ x . content_hash }` , } as RemoteItem ; } else if ( x [ ".tag" ] === "deleted" ) { throw Error ( "do not support deleted tag" ) ; } } ; param allFilesFolders returns const fixLastModifiedTimeInplace = ( allFilesFolders ) => { if ( allFilesFolders . length === 0 ) { return ; } allFilesFolders . sort ( ( a , b ) => b . key . length - a . key . length ) ; let potentialMTime = { } as Record < string , number > ; for ( const item of allFilesFolders ) { if ( item . key . endsWith ( "/" ) ) { if ( item . lastModified === undefined && item . key in potentialMTime ) { item . lastModified = potentialMTime [ item . key ] ; } } const parent = `${ path . posix . dirname ( item . key ) }/` ; if ( item . lastModified !== undefined ) { if ( parent in potentialMTime ) { potentialMTime [ parent ] = Math . max ( potentialMTime [ parent ] , item . lastModified ) ; } else { potentialMTime [ parent ] = item . lastModified ; } } } for ( let i = allFilesFolders . length - 1 ; i >= 0 ; -- i ) { const item = allFilesFolders [ i ] ; if ( ! item . key . endsWith ( "/" ) ) { continue ; } if ( item . lastModified !== undefined ) { continue ; } const parent = `${ path . posix . dirname ( item . key ) }/` ; if ( parent in potentialMTime ) { item . lastModified = potentialMTime [ parent ] ; } else { item . lastModified = Date . now ( ) . valueOf ( ) ; potentialMTime [ item . key ] = item . lastModified ; } } return allFilesFolders ; } ; export const getAuthUrlAndVerifier = async ( appKey ) => { const auth = new DropboxAuth ( { clientId : appKey , } ) ; const authUrl = ( await auth . getAuthenticationUrl ( `obsidian://${ COMMAND_CALLBACK_DROPBOX }` , undefined , "code" , "offline" , undefined , "none" , true ) ) . toString ( ) ; const verifier = auth . getCodeVerifier ( ) ; return { authUrl : authUrl , verifier : verifier , } ; } ; export interface DropboxSuccessAuthRes { access_token : string ; token_type : "bearer" ; expires_in : string ; refresh_token ? : string ; scope ? : string ; uid ? : string ; account_id ? : string ; } export const sendAuthReq = async ( appKey , verifier , authCode ) => { const resp1 = await fetch ( "https://api.dropboxapi.com/oauth2/token" , { method : "POST" , body : new URLSearchParams ( { code : authCode , grant_type : "authorization_code" , code_verifier : verifier , client_id : appKey , redirect_uri : `obsidian://${ COMMAND_CALLBACK_DROPBOX }` , } ) , } ) ; const resp2 = ( await resp1 . json ( ) ) as DropboxSuccessAuthRes ; return resp2 ; } ; export const sendRefreshTokenReq = async ( appKey , refreshToken ) => { log . info ( "start auto getting refreshed Dropbox access token." ) ; const resp1 = await fetch ( "https://api.dropboxapi.com/oauth2/token" , { method : "POST" , body : new URLSearchParams ( { grant_type : "refresh_token" , refresh_token : refreshToken , client_id : appKey , } ) , } ) ; const resp2 = ( await resp1 . json ( ) ) as DropboxSuccessAuthRes ; log . info ( "finish auto getting refreshed Dropbox access token." ) ; return resp2 ; } ; export const setConfigBySuccessfullAuthInplace = async ( config , authRes , saveUpdatedConfigFunc ) => { log . info ( "start updating local info of Dropbox token" ) ; config . accessToken = authRes . access_token ; config . accessTokenExpiresInSeconds = parseInt ( authRes . expires_in ) ; config . accessTokenExpiresAtTime = Date . now ( ) + parseInt ( authRes . expires_in ) * 1000 - 10 * 1000 ; config . credentialsShouldBeDeletedAtTime = Date . now ( ) + OAUTH2_FORCE_EXPIRE_MILLISECONDS ; if ( authRes . refresh_token !== undefined ) { config . refreshToken = authRes . refresh_token ; } if ( authRes . refresh_token !== undefined ) { config . accountID = authRes . account_id ; } if ( saveUpdatedConfigFunc !== undefined ) { await saveUpdatedConfigFunc ( ) ; } log . info ( "finish updating local info of Dropbox token" ) ; } ; export class WrappedDropboxClient { dropboxConfig : DropboxConfig ; vaultName : string ; saveUpdatedConfigFunc : ( ) => Promise < any > ; dropbox : Dropbox ; vaultFolderExists : boolean ; constructor ( dropboxConfig , vaultName , saveUpdatedConfigFunc ) { this . dropboxConfig = dropboxConfig ; this . vaultName = vaultName ; this . saveUpdatedConfigFunc = saveUpdatedConfigFunc ; this . vaultFolderExists = false ; } init = async ( ) => { if ( this . dropboxConfig . accessToken === "" || this . dropboxConfig . refreshToken === "" ) { throw Error ( "The user has not manually auth yet." ) ; } const currentTs = Date . now ( ) ; if ( this . dropboxConfig . accessTokenExpiresAtTime > currentTs ) { this . dropbox = new Dropbox ( { accessToken : this . dropboxConfig . accessToken , } ) ; } else { if ( this . dropboxConfig . refreshToken === "" ) { throw Error ( "We need to automatically refresh token but none is stored." ) ; } const resp = await sendRefreshTokenReq ( this . dropboxConfig . clientID , this . dropboxConfig . refreshToken ) ; setConfigBySuccessfullAuthInplace ( this . dropboxConfig , resp , this . saveUpdatedConfigFunc ) ; this . dropbox = new Dropbox ( { accessToken : this . dropboxConfig . accessToken , } ) ; } if ( this . vaultFolderExists ) { } else { const res = await this . dropbox . filesListFolder ( { path : "" , recursive : false , } ) ; for ( const item of res . result . entries ) { if ( item . path_display === `/${ this . vaultName }` ) { this . vaultFolderExists = true ; break ; } } if ( ! this . vaultFolderExists ) { log . info ( `remote does not have folder /${ this . vaultName }` ) ; await this . dropbox . filesCreateFolderV2 ( { path : `/${ this . vaultName }` , } ) ; log . info ( `remote folder /${ this . vaultName } created` ) ; this . vaultFolderExists = true ; } else { } } return this . dropbox ; } ; } param dropboxConfig returns export const getDropboxClient = ( dropboxConfig , vaultName , saveUpdatedConfigFunc ) => { return new WrappedDropboxClient ( dropboxConfig , vaultName , saveUpdatedConfigFunc ) ; } ; export const getRemoteMeta = async ( client , fileOrFolderPath ) => { await client . init ( ) ; if ( fileOrFolderPath === "" || fileOrFolderPath === "/" ) { const rsp = await client . dropbox . filesListFolder ( { path : `/${ client . vaultName }` , recursive : false , } ) ; if ( rsp . status !== 200 ) { throw Error ( JSON . stringify ( rsp ) ) ; } return { key : fileOrFolderPath , lastModified : undefined , size : 0 , remoteType : "dropbox" , etag : undefined , } as RemoteItem ; } const key = getDropboxPath ( fileOrFolderPath , client . vaultName ) ; const rsp = await client . dropbox . filesGetMetadata ( { path : key , } ) ; if ( rsp . status !== 200 ) { throw Error ( JSON . stringify ( rsp ) ) ; } return fromDropboxItemToRemoteItem ( rsp . result , client . vaultName ) ; } ; export const uploadToRemote = async ( client , fileOrFolderPath , vault , isRecursively = false , password = "" , remoteEncryptedKey = "" , foldersCreatedBefore = undefined ) => { await client . init ( ) ; let uploadFile = fileOrFolderPath ; if ( password !== "" ) { uploadFile = remoteEncryptedKey ; } uploadFile = getDropboxPath ( uploadFile , client . vaultName ) ; const isFolder = fileOrFolderPath . endsWith ( "/" ) ; if ( isFolder && isRecursively ) { throw Error ( "upload function doesn't implement recursive function yet!" ) ; } else if ( isFolder && ! isRecursively ) { if ( password === "" ) { if ( foldersCreatedBefore ?. has ( uploadFile ) ) { } else { try { await client . dropbox . filesCreateFolderV2 ( { path : uploadFile , } ) ; foldersCreatedBefore ?. add ( uploadFile ) ; } catch ( err ) { if ( err . status === 409 ) { foldersCreatedBefore ?. add ( uploadFile ) ; } else { throw err ; } } } const res = await getRemoteMeta ( client , uploadFile ) ; return res ; } else { await client . dropbox . filesUpload ( { path : uploadFile , contents : "" , } ) ; return await getRemoteMeta ( client , uploadFile ) ; } } else { const localContent = await vault . adapter . readBinary ( fileOrFolderPath ) ; let remoteContent = localContent ; if ( password !== "" ) { remoteContent = await encryptArrayBuffer ( localContent , password ) ; } await client . dropbox . filesUpload ( { path : uploadFile , contents : remoteContent , mode : { ".tag" : "overwrite" , } , } ) ; if ( foldersCreatedBefore !== undefined ) { const dirs = getFolderLevels ( uploadFile ) . map ( ( x ) => getDropboxPath ( x , client . vaultName ) ) ; for ( const dir of dirs ) { foldersCreatedBefore ?. add ( dir ) ; } } return await getRemoteMeta ( client , uploadFile ) ; } } ; export const listFromRemote = async ( client , prefix ? ) => { if ( prefix !== undefined ) { throw Error ( "prefix not supported (yet)" ) ; } await client . init ( ) ; let res = await client . dropbox . filesListFolder ( { path : `/${ client . vaultName }` , recursive : true , include_deleted : false , limit : 1000 , } ) ; if ( res . status !== 200 ) { throw Error ( JSON . stringify ( res ) ) ; } const contents = res . result . entries ; const unifiedContents = contents . filter ( ( x ) => x [ ".tag" ] !== "deleted" ) . filter ( ( x ) => x . path_display !== `/${ client . vaultName }` ) . map ( ( x ) => fromDropboxItemToRemoteItem ( x , client . vaultName ) ) ; while ( res . result . has_more ) { res = await client . dropbox . filesListFolderContinue ( { cursor : res . result . cursor , } ) ; if ( res . status !== 200 ) { throw Error ( JSON . stringify ( res ) ) ; } const contents2 = res . result . entries ; const unifiedContents2 = contents2 . filter ( ( x ) => x [ ".tag" ] !== "deleted" ) . filter ( ( x ) => x . path_display !== `/${ client . vaultName }` ) . map ( ( x ) => fromDropboxItemToRemoteItem ( x , client . vaultName ) ) ; unifiedContents . push ( ... unifiedContents2 ) ; } fixLastModifiedTimeInplace ( unifiedContents ) ; return { Contents : unifiedContents , } ; } ; const downloadFromRemoteRaw = async ( client , fileOrFolderPath ) => { await client . init ( ) ; const key = getDropboxPath ( fileOrFolderPath , client . vaultName ) ; const rsp = await client . dropbox . filesDownload ( { path : key , } ) ; if ( ( rsp . result as any ) . fileBlob !== undefined ) { const content = ( rsp . result as any ) . fileBlob as Blob ; return await content . arrayBuffer ( ) ; } else if ( ( rsp . result as any ) . fileBinary !== undefined ) { const content = ( rsp . result as any ) . fileBinary as Buffer ; return bufferToArrayBuffer ( content ) ; } else { throw Error ( `unknown rsp from dropbox download: ${ rsp }` ) ; } } ; export const downloadFromRemote = async ( client , fileOrFolderPath , vault , mtime , password = "" , remoteEncryptedKey = "" ) => { await client . init ( ) ; const isFolder = fileOrFolderPath . endsWith ( "/" ) ; await mkdirpInVault ( fileOrFolderPath , vault ) ; if ( isFolder ) { } else { let downloadFile = fileOrFolderPath ; if ( password !== "" ) { downloadFile = remoteEncryptedKey ; } downloadFile = getDropboxPath ( downloadFile , client . vaultName ) ; const remoteContent = await downloadFromRemoteRaw ( client , downloadFile ) ; let localContent = remoteContent ; if ( password !== "" ) { localContent = await decryptArrayBuffer ( remoteContent , password ) ; } await vault . adapter . writeBinary ( fileOrFolderPath , localContent , { mtime : mtime , } ) ; } } ; export const deleteFromRemote = async ( client , fileOrFolderPath , password = "" , remoteEncryptedKey = "" ) => { if ( fileOrFolderPath === "/" ) { return ; } let remoteFileName = fileOrFolderPath ; if ( password !== "" ) { remoteFileName = remoteEncryptedKey ; } remoteFileName = getDropboxPath ( remoteFileName , client . vaultName ) ; await client . init ( ) ; try { await client . dropbox . filesDeleteV2 ( { path : remoteFileName , } ) ; } catch ( err ) { console . error ( "some error while deleting" ) ; console . error ( err ) ; } } ; export const checkConnectivity = async ( client ) => { try { const results = await getRemoteMeta ( client , "/" ) ; if ( results === undefined ) { return false ; } return true ; } catch ( err ) { console . error ( "dropbox connectivity error:" ) ; console . error ( err ) ; return false ; } } ; export const getUserDisplayName = async ( client ) => { await client . init ( ) ; const acct = await client . dropbox . usersGetCurrentAccount ( ) ; return acct . result . name . display_name ; } ; export const revokeAuth = async ( client ) => { await client . init ( ) ; await client . dropbox . authTokenRevoke ( ) ; } ;