import { CryptoProvider , PublicClientApplication } from "@azure/msal-node" ; import { AuthenticationProvider , Client , FileUpload , LargeFileUploadSession , LargeFileUploadTask , LargeFileUploadTaskOptions , Range , UploadEventHandlers , UploadResult , } from "@microsoft/microsoft-graph-client" ; import type { DriveItem , User } from "@microsoft/microsoft-graph-types" ; import cloneDeep from "lodash/cloneDeep" ; import { request , Vault } from "obsidian" ; import * as path from "path" ; import { DropboxConfig , OAUTH2_FORCE_EXPIRE_MILLISECONDS , OnedriveConfig , RemoteItem , } from "./baseTypes" ; import { COMMAND_CALLBACK_ONEDRIVE } from "./baseTypes" ; import { decryptArrayBuffer , encryptArrayBuffer } from "./encrypt" ; import { getRandomArrayBuffer , getRandomIntInclusive , mkdirpInVault , } from "./misc" ; import * as origLog from "loglevel" ; const log = origLog . getLogger ( "rs-default" ) ; const SCOPES = [ "User.Read" , "Files.ReadWrite.AppFolder" , "offline_access" ] ; const REDIRECT_URI = `obsidian://${ COMMAND_CALLBACK_ONEDRIVE }` ; export const DEFAULT_ONEDRIVE_CONFIG = { accessToken : "" , clientID : process . env . DEFAULT_ONEDRIVE_CLIENT_ID , authority : process . env . DEFAULT_ONEDRIVE_AUTHORITY , refreshToken : "" , accessTokenExpiresInSeconds : 0 , accessTokenExpiresAtTime : 0 , deltaLink : "" , username : "" , credentialsShouldBeDeletedAtTime : 0 , } ; export async function getAuthUrlAndVerifier ( clientID , authority ) { const cryptoProvider = new CryptoProvider ( ) ; const { verifier , challenge } = await cryptoProvider . generatePkceCodes ( ) ; const pkceCodes = { challengeMethod : "S256" , verifier : verifier , challenge : challenge , } ; const authCodeUrlParams = { redirectUri : REDIRECT_URI , scopes : SCOPES , codeChallenge : pkceCodes . challenge , codeChallengeMethod : pkceCodes . challengeMethod , } ; const pca = new PublicClientApplication ( { auth : { clientId : clientID , authority : authority , } , } ) ; const authCodeUrl = await pca . getAuthCodeUrl ( authCodeUrlParams ) ; return { authUrl : authCodeUrl , verifier : verifier , } ; } /**
 * Check doc from
 * https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
 * https://docs.microsoft.com/en-us/onedrive/developer/rest-api/getting-started/graph-oauth?view=odsp-graph-online#code-flow
 */ export interface AccessCodeResponseSuccessfulType { token_type : "Bearer" | "bearer" ; expires_in : number ; ext_expires_in ? : number ; scope : string ; access_token : string ; refresh_token ? : string ; id_token ? : string ; } export interface AccessCodeResponseFailedType { error : string ; error_description : string ; error_codes : number [ ] ; timestamp : string ; trace_id : string ; correlation_id : string ; } export const sendAuthReq = async ( clientID , authority , authCode , verifier ) => { const rsp1 = await request ( { url : `${ authority }/oauth2/v2.0/token` , method : "POST" , contentType : "application/x-www-form-urlencoded" , body : new URLSearchParams ( { tenant : "consumers" , client_id : clientID , scope : SCOPES . join ( " " ) , code : authCode , redirect_uri : REDIRECT_URI , grant_type : "authorization_code" , code_verifier : verifier , } ) . toString ( ) , } ) ; const rsp2 = JSON . parse ( rsp1 ) ; if ( rsp2 . error !== undefined ) { return rsp2 as AccessCodeResponseFailedType ; } else { return rsp2 as AccessCodeResponseSuccessfulType ; } } ; export const sendRefreshTokenReq = async ( clientID , authority , refreshToken ) => { const rsp1 = await request ( { url : `${ authority }/oauth2/v2.0/token` , method : "POST" , contentType : "application/x-www-form-urlencoded" , body : new URLSearchParams ( { tenant : "consumers" , client_id : clientID , scope : SCOPES . join ( " " ) , refresh_token : refreshToken , grant_type : "refresh_token" , } ) . toString ( ) , } ) ; const rsp2 = JSON . parse ( rsp1 ) ; if ( rsp2 . error !== undefined ) { return rsp2 as AccessCodeResponseFailedType ; } else { return rsp2 as AccessCodeResponseSuccessfulType ; } } ; export const setConfigBySuccessfullAuthInplace = async ( config , authRes , saveUpdatedConfigFunc ) => { log . info ( "start updating local info of OneDrive token" ) ; config . accessToken = authRes . access_token ; config . accessTokenExpiresAtTime = Date . now ( ) + authRes . expires_in - 5 * 60 * 1000 ; config . accessTokenExpiresInSeconds = authRes . expires_in ; config . refreshToken = authRes . refresh_token ; config . credentialsShouldBeDeletedAtTime = Date . now ( ) + OAUTH2_FORCE_EXPIRE_MILLISECONDS ; if ( saveUpdatedConfigFunc !== undefined ) { await saveUpdatedConfigFunc ( ) ; } log . info ( "finish updating local info of Onedrive token" ) ; } ; const getOnedrivePath = ( fileOrFolderPath , vaultName ) => { const prefix = `/drive/special/approot:/${ vaultName }` ; if ( fileOrFolderPath . startsWith ( prefix ) ) { return fileOrFolderPath ; } let key = fileOrFolderPath ; if ( fileOrFolderPath === "/" || fileOrFolderPath === "" ) { return prefix ; } if ( key . endsWith ( "/" ) ) { key = key . slice ( 0 , key . length - 1 ) ; } key = `${ prefix }/${ key }` ; return key ; } ; const getNormPath = ( fileOrFolderPath , vaultName ) => { const prefix = `/drive/special/approot:/${ vaultName }` ; if ( ! ( fileOrFolderPath === prefix || fileOrFolderPath . startsWith ( `${ prefix }/` ) ) ) { throw Error ( `"${ fileOrFolderPath }" doesn't starts with "${ prefix }/" or equals to "${ prefix }"` ) ; } if ( fileOrFolderPath === prefix ) { return "/" ; } return fileOrFolderPath . slice ( `${ prefix }/` . length ) ; } ; const constructFromDriveItemToRemoteItemError = ( x ) => { return `parentPath="${ x . parentReference . path }", selfName="${ x . name }"` ; } ; const fromDriveItemToRemoteItem = ( x , vaultName ) : RemoteItem => { let key = "" ; const FIRST_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/remotely-save\//g ; const SECOND_COMMON_PREFIX_RAW = `/drive/items/` ; const fullPathOriginal = `${ x . parentReference . path }/${ x . name }` ; const matchFirstPrefixRes = fullPathOriginal . match ( FIRST_COMMON_PREFIX_REGEX ) ; if ( matchFirstPrefixRes !== null && fullPathOriginal . startsWith ( `${ matchFirstPrefixRes [ 0 ] }${ vaultName }` ) ) { const foundPrefix = `${ matchFirstPrefixRes [ 0 ] }${ vaultName }` ; key = fullPathOriginal . substring ( foundPrefix . length + 1 ) ; } else if ( x . parentReference . path . startsWith ( SECOND_COMMON_PREFIX_RAW ) ) { const parPath = decodeURIComponent ( x . parentReference . path ) ; key = parPath . substring ( parPath . indexOf ( ":" ) + 1 ) ; if ( key . startsWith ( `/${ vaultName }/` ) ) { key = key . substring ( `/${ vaultName }/` . length ) ; key = `${ key }/${ x . name }` ; } else if ( key === `/${ vaultName }` ) { key = x . name ; } else { throw Error ( `we meet file/folder and do not know how to deal with it:\n${ constructFromDriveItemToRemoteItemError ( x ) }` ) ; } } else { throw Error ( `we meet file/folder and do not know how to deal with it:\n${ constructFromDriveItemToRemoteItemError ( x ) }` ) ; } const isFolder = "folder" in x ; if ( isFolder ) { key = `${ key }/` ; } return { key : key , lastModified : Date . parse ( x . fileSystemInfo . lastModifiedDateTime ) , size : isFolder ? 0 : x . size , remoteType : "onedrive" , etag : x . eTag || x . cTag || "" , } ; } ; class MyAuthProvider implements AuthenticationProvider { onedriveConfig : OnedriveConfig ; saveUpdatedConfigFunc : ( ) => Promise < any > ; constructor ( onedriveConfig , saveUpdatedConfigFunc ) { this . onedriveConfig = onedriveConfig ; this . saveUpdatedConfigFunc = saveUpdatedConfigFunc ; } getAccessToken = async ( ) => { if ( this . onedriveConfig . accessToken === "" || this . onedriveConfig . refreshToken === "" ) { throw Error ( "The user has not manually auth yet." ) ; } const currentTs = Date . now ( ) ; if ( this . onedriveConfig . accessTokenExpiresAtTime > currentTs ) { return this . onedriveConfig . accessToken ; } else { const r = await sendRefreshTokenReq ( this . onedriveConfig . clientID , this . onedriveConfig . authority , this . onedriveConfig . refreshToken ) ; if ( ( r as any ) . error !== undefined ) { const r2 = r as AccessCodeResponseFailedType ; throw Error ( `Error while refreshing accessToken: ${ r2 . error }, ${ r2 . error_codes }: ${ r2 . error_description }` ) ; } const r2 = r as AccessCodeResponseSuccessfulType ; this . onedriveConfig . accessToken = r2 . access_token ; this . onedriveConfig . refreshToken = r2 . refresh_token ; this . onedriveConfig . accessTokenExpiresInSeconds = r2 . expires_in ; this . onedriveConfig . accessTokenExpiresAtTime = currentTs + r2 . expires_in * 1000 - 60 * 2 * 1000 ; await this . saveUpdatedConfigFunc ( ) ; log . info ( "Onedrive accessToken updated" ) ; return this . onedriveConfig . accessToken ; } } ; } export class WrappedOnedriveClient { onedriveConfig : OnedriveConfig ; vaultName : string ; client : Client ; vaultFolderExists : boolean ; saveUpdatedConfigFunc : ( ) => Promise < any > ; constructor ( onedriveConfig , vaultName , saveUpdatedConfigFunc ) { this . onedriveConfig = onedriveConfig ; this . vaultName = vaultName ; this . vaultFolderExists = false ; this . saveUpdatedConfigFunc = saveUpdatedConfigFunc ; this . client = Client . initWithMiddleware ( { authProvider : new MyAuthProvider ( onedriveConfig , saveUpdatedConfigFunc ) , } ) ; } init = async ( ) => { if ( this . onedriveConfig . accessToken === "" || this . onedriveConfig . refreshToken === "" ) { throw Error ( "The user has not manually auth yet." ) ; } if ( this . vaultFolderExists ) { } else { const k = await this . client . api ( "/drive/special/approot/children" ) . get ( ) ; this . vaultFolderExists = ( k . value as DriveItem [ ] ) . filter ( ( x ) => x . name === this . vaultName ) . length > 0 ; if ( ! this . vaultFolderExists ) { log . info ( `remote does not have folder /${ this . vaultName }` ) ; await this . client . api ( "/drive/special/approot/children" ) . post ( { name : `${ this . vaultName }` , folder : { } , "@microsoft.graph.conflictBehavior" : "replace" , } ) ; log . info ( `remote folder /${ this . vaultName } created` ) ; this . vaultFolderExists = true ; } else { } } } ; } export const getOnedriveClient = ( onedriveConfig , vaultName , saveUpdatedConfigFunc ) => { return new WrappedOnedriveClient ( onedriveConfig , vaultName , saveUpdatedConfigFunc ) ; } ; param client param prefix export const listFromRemote = async ( client , prefix ? ) => { if ( prefix !== undefined ) { throw Error ( "prefix not supported (yet)" ) ; } await client . init ( ) ; const NEXT_LINK_KEY = "@odata.nextLink" ; const DELTA_LINK_KEY = "@odata.deltaLink" ; let res = await client . client . api ( `/drive/special/approot:/${ client . vaultName }:/delta` ) . get ( ) ; let driveItems = res . value as DriveItem [ ] ; while ( NEXT_LINK_KEY in res ) { res = await client . client . api ( res [ NEXT_LINK_KEY ] ) . get ( ) ; driveItems . push ( ... cloneDeep ( res . value as DriveItem [ ] ) ) ; } if ( DELTA_LINK_KEY in res ) { client . onedriveConfig . deltaLink = res [ DELTA_LINK_KEY ] ; await client . saveUpdatedConfigFunc ( ) ; } driveItems = driveItems . map ( ( x ) => { const y = cloneDeep ( x ) ; y . parentReference . path = y . parentReference . path . replace ( "/Apps" , "/应用" ) ; return y ; } ) ; const unifiedContents = driveItems . map ( ( x ) => fromDriveItemToRemoteItem ( x , client . vaultName ) ) . filter ( ( x ) => x . key !== "/" ) ; return { Contents : unifiedContents , } ; } ; export const getRemoteMeta = async ( client , fileOrFolderPath ) => { await client . init ( ) ; const remotePath = getOnedrivePath ( fileOrFolderPath , client . vaultName ) ; const rsp = await client . client . api ( remotePath ) . select ( "cTag,eTag,fileSystemInfo,folder,file,name,parentReference,size" ) . get ( ) ; const driveItem = rsp as DriveItem ; const res = fromDriveItemToRemoteItem ( driveItem , client . vaultName ) ; return res ; } ; export const uploadToRemote = async ( client , fileOrFolderPath , vault , isRecursively = false , password = "" , remoteEncryptedKey = "" , foldersCreatedBefore = undefined ) => { await client . init ( ) ; let uploadFile = fileOrFolderPath ; if ( password !== "" ) { uploadFile = remoteEncryptedKey ; } uploadFile = getOnedrivePath ( 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 { await client . client . api ( uploadFile ) . patch ( { folder : { } , "@microsoft.graph.conflictBehavior" : "replace" , } ) ; } const res = await getRemoteMeta ( client , uploadFile ) ; return res ; } else { const byteLengthRandom = getRandomIntInclusive ( 1 , 65536 ) ; const arrBufRandom = await encryptArrayBuffer ( getRandomArrayBuffer ( byteLengthRandom ) , password ) ; const uploadSession = await LargeFileUploadTask . createUploadSession ( client . client , `https://graph.microsoft.com/v1.0/me${ encodeURIComponent ( uploadFile ) }:/createUploadSession` , { item : { "@microsoft.graph.conflictBehavior" : "replace" , } , } ) ; const task = new LargeFileUploadTask ( client . client , new FileUpload ( arrBufRandom , path . posix . basename ( uploadFile ) , arrBufRandom . byteLength ) , uploadSession , { rangeSize : 1024 * 1024 , uploadEventHandlers : { progress : ( range ? ) => { } , } as UploadEventHandlers , } as LargeFileUploadTaskOptions ) ; const uploadResult = await task . upload ( ) ; const res = await getRemoteMeta ( client , uploadFile ) ; return res ; } } else { const localContent = await vault . adapter . readBinary ( fileOrFolderPath ) ; let remoteContent = localContent ; if ( password !== "" ) { remoteContent = await encryptArrayBuffer ( localContent , password ) ; } const progress = ( range ? ) => { } ; const uploadEventHandlers = { progress : progress , } ; const options = { rangeSize : 1024 * 1024 , uploadEventHandlers : uploadEventHandlers , } ; const payload = { item : { "@microsoft.graph.conflictBehavior" : "replace" , } , } ; const uploadSession = await LargeFileUploadTask . createUploadSession ( client . client , `https://graph.microsoft.com/v1.0/me${ encodeURIComponent ( uploadFile ) }:/createUploadSession` , payload ) ; const fileObject = new FileUpload ( remoteContent , path . posix . basename ( uploadFile ) , remoteContent . byteLength ) ; const task = new LargeFileUploadTask ( client . client , fileObject , uploadSession , options ) ; const uploadResult = await task . upload ( ) ; const res = await getRemoteMeta ( client , uploadFile ) ; return res ; } } ; const downloadFromRemoteRaw = async ( client , fileOrFolderPath ) : Promise < ArrayBuffer > => { await client . init ( ) ; const key = getOnedrivePath ( fileOrFolderPath , client . vaultName ) ; const rsp = await client . client . api ( key ) . select ( "@microsoft.graph.downloadUrl" ) . get ( ) ; const downloadUrl = rsp [ "@microsoft.graph.downloadUrl" ] ; const content = await ( await fetch ( downloadUrl ) ) . arrayBuffer ( ) ; return content ; } ; 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 = getOnedrivePath ( 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 = getOnedrivePath ( remoteFileName , client . vaultName ) ; await client . init ( ) ; await client . client . api ( remoteFileName ) . delete ( ) ; } ; export const checkConnectivity = async ( client ) => { try { const k = await getUserDisplayName ( client ) ; return k !== "<unknown display name>" ; } catch ( err ) { return false ; } } ; export const getUserDisplayName = async ( client ) => { await client . init ( ) ; const res = await client . client . api ( "/me" ) . select ( "displayName" ) . get ( ) ; return res . displayName || "<unknown display name>" ; } ; param client export const getRevokeAddr = async ( ) => { return "https://account.live.com/consent/Manage" ; } ;