2021-01-20 17:49:02 +02:00
import shim from './shim' ;
import time from './time' ;
import Logger from './Logger' ;
import { _ } from './locale' ;
2017-11-03 02:09:34 +02:00
const { stringify } = require ( 'query-string' ) ;
2020-11-05 18:58:23 +02:00
const urlUtils = require ( './urlUtils.js' ) ;
2021-01-02 17:09:33 +02:00
const Buffer = require ( 'buffer' ) . Buffer ;
2017-06-22 21:44:38 +02:00
2021-01-20 17:49:02 +02:00
const logger = Logger . create ( 'OneDriveApi' ) ;
export default class OneDriveApi {
private clientId_ : string ;
private clientSecret_ : string ;
private auth_ : any = null ;
private accountProperties_ : any = null ;
private isPublic_ : boolean ;
private listeners_ : Record < string , any > ;
2017-07-07 00:15:31 +02:00
// `isPublic` is to tell OneDrive whether the application is a "public" one (Mobile and desktop
// apps are considered "public"), in which case the secret should not be sent to the API.
// In practice the React Native app is public, and the Node one is not because we
// use a local server for the OAuth dance.
2021-01-20 17:49:02 +02:00
constructor ( clientId : string , clientSecret : string , isPublic : boolean ) {
2017-06-22 21:44:38 +02:00
this . clientId_ = clientId ;
this . clientSecret_ = clientSecret ;
2017-06-22 23:52:27 +02:00
this . auth_ = null ;
2020-08-08 01:35:30 +02:00
this . accountProperties_ = null ;
2017-07-07 00:15:31 +02:00
this . isPublic_ = isPublic ;
2017-06-23 20:51:02 +02:00
this . listeners_ = {
2019-07-29 15:43:53 +02:00
authRefreshed : [ ] ,
2017-06-23 20:51:02 +02:00
} ;
}
2017-07-07 00:15:31 +02:00
isPublic() {
return this . isPublic_ ;
}
2021-01-20 17:49:02 +02:00
dispatch ( eventName : string , param : any ) {
2020-03-14 01:46:14 +02:00
const ls = this . listeners_ [ eventName ] ;
2017-06-23 20:51:02 +02:00
for ( let i = 0 ; i < ls . length ; i ++ ) {
ls [ i ] ( param ) ;
}
}
2021-01-20 17:49:02 +02:00
on ( eventName : string , callback : Function ) {
2017-06-23 20:51:02 +02:00
this . listeners_ [ eventName ] . push ( callback ) ;
2017-06-22 21:44:38 +02:00
}
2017-06-22 23:52:27 +02:00
tokenBaseUrl() {
return 'https://login.microsoftonline.com/common/oauth2/v2.0/token' ;
}
2017-11-06 20:35:04 +02:00
nativeClientRedirectUrl() {
return 'https://login.microsoftonline.com/common/oauth2/nativeclient' ;
}
2021-01-20 17:49:02 +02:00
auth ( ) : any {
2017-07-06 20:58:01 +02:00
return this . auth_ ;
}
2021-01-20 17:49:02 +02:00
setAuth ( auth : any ) {
2017-06-22 23:52:27 +02:00
this . auth_ = auth ;
2017-07-07 00:15:31 +02:00
this . dispatch ( 'authRefreshed' , this . auth ( ) ) ;
2017-06-22 23:52:27 +02:00
}
token() {
return this . auth_ ? this . auth_.access_token : null ;
2017-06-22 21:44:38 +02:00
}
clientId() {
return this . clientId_ ;
}
clientSecret() {
return this . clientSecret_ ;
}
2017-06-22 23:52:27 +02:00
async appDirectory() {
2020-08-08 01:35:30 +02:00
const driveId = this . accountProperties_ . driveId ;
const r = await this . execJson ( 'GET' , ` /me/drives/ ${ driveId } /special/approot ` ) ;
2019-09-19 23:51:18 +02:00
return ` ${ r . parentReference . path } / ${ r . name } ` ;
2017-06-22 23:52:27 +02:00
}
2021-01-20 17:49:02 +02:00
authCodeUrl ( redirectUri : string ) {
2020-03-14 01:46:14 +02:00
const query = {
2017-06-22 21:44:38 +02:00
client_id : this.clientId_ ,
2020-08-08 01:35:30 +02:00
scope : 'files.readwrite offline_access sites.readwrite.all' ,
2017-06-22 21:44:38 +02:00
response_type : 'code' ,
redirect_uri : redirectUri ,
} ;
2019-09-19 23:51:18 +02:00
return ` https://login.microsoftonline.com/common/oauth2/v2.0/authorize? ${ stringify ( query ) } ` ;
2017-06-22 21:44:38 +02:00
}
2021-01-20 17:49:02 +02:00
async execTokenRequest ( code : string , redirectUri : string ) {
const body : any = { } ;
2020-10-28 17:50:34 +02:00
body [ 'client_id' ] = this . clientId ( ) ;
if ( ! this . isPublic ( ) ) body [ 'client_secret' ] = this . clientSecret ( ) ;
body [ 'code' ] = code ;
body [ 'redirect_uri' ] = redirectUri ;
body [ 'grant_type' ] = 'authorization_code' ;
2017-07-06 21:29:09 +02:00
const r = await shim . fetch ( this . tokenBaseUrl ( ) , {
method : 'POST' ,
2020-10-28 17:50:34 +02:00
body : urlUtils.objectToQueryString ( body ) ,
2020-10-16 17:26:19 +02:00
headers : {
[ 'Content-Type' ] : 'application/x-www-form-urlencoded' ,
} ,
2019-07-29 15:43:53 +02:00
} ) ;
2017-07-06 21:29:09 +02:00
if ( ! r . ok ) {
const text = await r . text ( ) ;
2019-09-19 23:51:18 +02:00
throw new Error ( ` Could not retrieve auth code: ${ r . status } : ${ r . statusText } : ${ text } ` ) ;
2017-07-06 21:29:09 +02:00
}
try {
const json = await r . json ( ) ;
this . setAuth ( json ) ;
} catch ( error ) {
2017-07-07 00:15:31 +02:00
this . setAuth ( null ) ;
2017-07-06 21:29:09 +02:00
const text = await r . text ( ) ;
2019-09-19 23:51:18 +02:00
error . message += ` : ${ text } ` ;
2017-07-06 21:29:09 +02:00
throw error ;
}
}
2021-01-20 17:49:02 +02:00
oneDriveErrorResponseToError ( errorResponse : any ) {
2017-06-29 20:03:16 +02:00
if ( ! errorResponse ) return new Error ( 'Undefined error' ) ;
if ( errorResponse . error ) {
2020-03-14 01:46:14 +02:00
const e = errorResponse . error ;
2021-01-20 17:49:02 +02:00
const output : any = new Error ( e . message ) ;
2017-06-29 20:03:16 +02:00
if ( e . code ) output . code = e . code ;
if ( e . innerError ) output . innerError = e . innerError ;
return output ;
2019-07-29 15:43:53 +02:00
} else {
2017-06-29 20:03:16 +02:00
return new Error ( JSON . stringify ( errorResponse ) ) ;
}
}
2021-01-20 17:49:02 +02:00
async uploadChunk ( url : string , handle : any , buffer : any , options : any ) {
2020-06-03 15:29:47 +02:00
options = Object . assign ( { } , options ) ;
if ( ! options . method ) { options . method = 'POST' ; }
2021-01-02 17:09:33 +02:00
if ( ! options . contentLength ) throw new Error ( 'uploadChunk: contentLength is missing' ) ;
if ( ! options . headers ) throw new Error ( 'uploadChunk: header is missing' ) ;
if ( buffer ) {
options . body = buffer . slice ( options . startByte , options . startByte + options . contentLength ) ;
} else {
const chunk = await shim . fsDriver ( ) . readFileChunk ( handle , options . contentLength ) ;
const buffer = Buffer . from ( chunk , 'base64' ) ;
options . body = buffer ;
}
2020-06-03 15:29:47 +02:00
delete options . contentLength ;
2021-01-02 17:09:33 +02:00
delete options . startByte ;
2020-06-03 15:29:47 +02:00
2021-01-02 17:09:33 +02:00
const response = await shim . fetch ( url , options ) ;
2020-06-03 15:29:47 +02:00
return response ;
}
2021-01-20 17:49:02 +02:00
async uploadBigFile ( url : string , options : any ) {
2020-06-03 15:29:47 +02:00
const response = await shim . fetch ( url , {
method : 'POST' ,
headers : {
'Authorization' : options . headers . Authorization ,
'Content-Type' : 'application/json' ,
} ,
} ) ;
if ( ! response . ok ) {
return response ;
} else {
const uploadUrl = ( await response . json ( ) ) . uploadUrl ;
const chunkSize = 7.5 * 1024 * 1024 ;
2021-01-02 17:09:33 +02:00
let byteSize = null ;
let handle = null ;
let buffer = null ;
if ( options . body ) {
byteSize = Buffer . byteLength ( options . body ) ;
buffer = Buffer . from ( options . body ) ;
} else {
byteSize = ( await shim . fsDriver ( ) . stat ( options . path ) ) . size ;
handle = await shim . fsDriver ( ) . open ( options . path , 'r' ) ;
}
const numberOfChunks = Math . ceil ( byteSize / chunkSize ) ;
2020-06-03 15:29:47 +02:00
try {
for ( let i = 0 ; i < numberOfChunks ; i ++ ) {
const startByte = i * chunkSize ;
let endByte = null ;
let contentLength = null ;
if ( i === numberOfChunks - 1 ) {
// Last fragment. It is not ensured that the last fragment is a multiple of 327,680 bytes as recommanded in the api doc. The reasons is that the docs are out of day for this purpose: https://github.com/OneDrive/onedrive-api-docs/issues/1200#issuecomment-597281253
2021-01-02 17:09:33 +02:00
endByte = byteSize - 1 ;
contentLength = byteSize - ( ( numberOfChunks - 1 ) * chunkSize ) ;
2020-06-03 15:29:47 +02:00
} else {
endByte = ( i + 1 ) * chunkSize - 1 ;
contentLength = chunkSize ;
}
2021-01-20 17:49:02 +02:00
logger . debug ( ` Uploading File Fragment ${ ( startByte / 1048576 ) . toFixed ( 2 ) } - ${ ( endByte / 1048576 ) . toFixed ( 2 ) } from ${ ( byteSize / 1048576 ) . toFixed ( 2 ) } Mbit ... ` ) ;
2020-06-03 15:29:47 +02:00
const headers = {
'Content-Length' : contentLength ,
2021-01-02 17:09:33 +02:00
'Content-Range' : ` bytes ${ startByte } - ${ endByte } / ${ byteSize } ` ,
2020-06-03 15:29:47 +02:00
'Content-Type' : 'application/octet-stream; charset=utf-8' ,
} ;
2021-01-02 17:09:33 +02:00
const response = await this . uploadChunk ( uploadUrl , handle , buffer , { startByte : startByte , contentLength : contentLength , method : 'PUT' , headers : headers } ) ;
2020-06-03 15:29:47 +02:00
if ( ! response . ok ) {
return response ;
}
2021-01-02 17:09:33 +02:00
2020-06-03 15:29:47 +02:00
}
return { ok : true } ;
} catch ( error ) {
2021-01-02 17:09:33 +02:00
const type = ( handle ) ? 'Resource' : 'Note Content' ;
2021-01-20 17:49:02 +02:00
logger . error ( ` Couldn't upload ${ type } > 4 Mb. Got unhandled error: ` , error ? error . code : '' , error ? error . message : '' , error ) ;
2020-06-03 15:29:47 +02:00
throw error ;
} finally {
2021-01-02 17:09:33 +02:00
if ( handle ) await shim . fsDriver ( ) . close ( handle ) ;
2020-06-03 15:29:47 +02:00
}
2021-01-02 17:09:33 +02:00
2020-06-03 15:29:47 +02:00
}
}
2021-01-20 17:49:02 +02:00
async exec ( method : string , path : string , query : any = null , data : any = null , options : any = null ) {
2017-07-24 20:01:40 +02:00
if ( ! path ) throw new Error ( 'Path is required' ) ;
2017-06-22 21:44:38 +02:00
method = method . toUpperCase ( ) ;
if ( ! options ) options = { } ;
if ( ! options . headers ) options . headers = { } ;
2017-07-06 23:30:45 +02:00
if ( ! options . target ) options . target = 'string' ;
2017-06-22 21:44:38 +02:00
if ( method != 'GET' ) {
options . method = method ;
}
2017-06-23 23:32:24 +02:00
if ( method == 'PATCH' || method == 'POST' ) {
2017-06-22 21:44:38 +02:00
options . headers [ 'Content-Type' ] = 'application/json' ;
if ( data ) data = JSON . stringify ( data ) ;
}
2017-06-29 20:03:16 +02:00
let url = path ;
2017-06-22 21:44:38 +02:00
2017-06-29 20:03:16 +02:00
// In general, `path` contains a path relative to the base URL, but in some
// cases the full URL is provided (for example, when it's a URL that was
// retrieved from the API).
2020-08-02 13:28:50 +02:00
if ( url . indexOf ( 'https://' ) !== 0 ) {
const slash = path . indexOf ( '/' ) === 0 ? '' : '/' ;
url = ` https://graph.microsoft.com/v1.0 ${ slash } ${ path } ` ;
}
2017-06-29 20:03:16 +02:00
if ( query ) {
url += url . indexOf ( '?' ) < 0 ? '?' : '&' ;
url += stringify ( query ) ;
}
2017-06-22 21:44:38 +02:00
if ( data ) options . body = data ;
2017-10-15 13:13:09 +02:00
options . timeout = 1000 * 60 * 5 ; // in ms
2017-06-23 20:51:02 +02:00
for ( let i = 0 ; i < 5 ; i ++ ) {
2019-09-19 23:51:18 +02:00
options . headers [ 'Authorization' ] = ` bearer ${ this . token ( ) } ` ;
2017-06-22 23:52:27 +02:00
2021-01-20 17:49:02 +02:00
const handleRequestRepeat = async ( error : any ) = > {
logger . info ( ` Got error below - retrying ( ${ i } )... ` ) ;
logger . info ( error ) ;
await time . sleep ( ( i + 1 ) * 5 ) ;
} ;
2017-07-06 23:30:45 +02:00
let response = null ;
2017-07-13 00:32:08 +02:00
try {
2021-01-02 17:09:33 +02:00
if ( path . includes ( '/createUploadSession' ) ) {
response = await this . uploadBigFile ( url , options ) ;
} else if ( options . source == 'file' && ( method == 'POST' || method == 'PUT' ) ) {
2021-01-23 17:51:19 +02:00
response = await shim . uploadBlob ( url , options ) ;
2017-08-01 23:40:14 +02:00
} else if ( options . target == 'string' ) {
2017-07-13 00:32:08 +02:00
response = await shim . fetch ( url , options ) ;
2019-07-29 15:43:53 +02:00
} else {
// file
2017-07-13 00:32:08 +02:00
response = await shim . fetchBlob ( url , options ) ;
}
} catch ( error ) {
2021-01-20 17:49:02 +02:00
if ( shim . fetchRequestCanBeRetried ( error ) ) {
await handleRequestRepeat ( error ) ;
continue ;
} else {
logger . error ( 'Got unhandled error:' , error ? error . code : '' , error ? error . message : '' , error ) ;
throw error ;
}
2017-07-06 23:30:45 +02:00
}
2017-06-22 23:52:27 +02:00
if ( ! response . ok ) {
2020-03-14 01:46:14 +02:00
const errorResponseText = await response . text ( ) ;
2017-11-30 20:29:10 +02:00
let errorResponse = null ;
2021-01-20 17:49:02 +02:00
2017-11-30 20:29:10 +02:00
try {
2019-10-09 21:35:13 +02:00
errorResponse = JSON . parse ( errorResponseText ) ; // await response.json();
2017-11-30 20:29:10 +02:00
} catch ( error ) {
2019-09-19 23:51:18 +02:00
error . message = ` OneDriveApi::exec: Cannot parse JSON error: ${ errorResponseText } ${ error . message } ` ;
2021-01-20 17:49:02 +02:00
await handleRequestRepeat ( error ) ;
continue ;
2017-11-30 20:29:10 +02:00
}
2020-03-14 01:46:14 +02:00
const error = this . oneDriveErrorResponseToError ( errorResponse ) ;
2017-06-22 23:52:27 +02:00
2017-07-08 00:25:03 +02:00
if ( error . code == 'InvalidAuthenticationToken' || error . code == 'unauthenticated' ) {
2021-01-20 17:49:02 +02:00
logger . info ( 'Token expired: refreshing...' ) ;
2017-06-22 23:52:27 +02:00
await this . refreshAccessToken ( ) ;
continue ;
2017-07-10 01:20:38 +02:00
} else if ( error && ( ( error . error && error . error . code == 'generalException' ) || error . code == 'generalException' || error . code == 'EAGAIN' ) ) {
2017-07-06 21:48:17 +02:00
// Rare error (one Google hit) - I guess the request can be repeated
// { error:
// { code: 'generalException',
// message: 'An error occurred in the data store.',
// innerError:
// { 'request-id': 'b4310552-c18a-45b1-bde1-68e2c2345eef',
// date: '2017-06-29T00:15:50' } } }
2017-07-10 01:20:38 +02:00
2017-07-06 21:48:17 +02:00
// { FetchError: request to https://graph.microsoft.com/v1.0/drive/root:/Apps/Joplin/.sync/7ee5dc04afcb414aa7c684bfc1edba8b.md_1499352102856 failed, reason: connect EAGAIN 65.52.64.250:443 - Local (0.0.0.0:54374)
// name: 'FetchError',
// message: 'request to https://graph.microsoft.com/v1.0/drive/root:/Apps/Joplin/.sync/7ee5dc04afcb414aa7c684bfc1edba8b.md_1499352102856 failed, reason: connect EAGAIN 65.52.64.250:443 - Local (0.0.0.0:54374)',
// type: 'system',
// errno: 'EAGAIN',
// code: 'EAGAIN' }
2021-01-20 17:49:02 +02:00
await handleRequestRepeat ( error ) ;
2017-12-05 01:01:22 +02:00
continue ;
2017-12-31 16:23:05 +02:00
} else if ( error && ( error . code === 'resourceModified' || ( error . error && error . error . code === 'resourceModified' ) ) ) {
2017-12-05 01:01:22 +02:00
// NOTE: not tested, very hard to reproduce and non-informative error message, but can be repeated
// Error: ETag does not match current item's value
// Code: resourceModified
// Header: {"_headers":{"cache-control":["private"],"transfer-encoding":["chunked"],"content-type":["application/json"],"request-id":["d...ea47"],"client-request-id":["d99...ea47"],"x-ms-ags-diagnostic":["{\"ServerInfo\":{\"DataCenter\":\"North Europe\",\"Slice\":\"SliceA\",\"Ring\":\"2\",\"ScaleUnit\":\"000\",\"Host\":\"AGSFE_IN_13\",\"ADSiteName\":\"DUB\"}}"],"duration":["96.9464"],"date":[],"connection":["close"]}}
// Request: PATCH https://graph.microsoft.com/v1.0/drive/root:/Apps/JoplinDev/f56c5601fee94b8085524513bf3e352f.md null "{\"fileSystemInfo\":{\"lastModifiedDateTime\":\"....\"}}" {"headers":{"Content-Type":"application/json","Authorization":"bearer ...
2021-01-20 17:49:02 +02:00
await handleRequestRepeat ( error ) ;
2017-07-06 21:48:17 +02:00
continue ;
2017-07-11 01:17:03 +02:00
} else if ( error . code == 'itemNotFound' && method == 'DELETE' ) {
// Deleting a non-existing item is ok - noop
return ;
2017-06-22 23:52:27 +02:00
} else {
2019-09-19 23:51:18 +02:00
error . request = ` ${ method } ${ url } ${ JSON . stringify ( query ) } ${ JSON . stringify ( data ) } ${ JSON . stringify ( options ) } ` ;
2017-10-26 23:57:49 +02:00
error . headers = await response . headers ;
2017-06-22 23:52:27 +02:00
throw error ;
}
}
2017-06-22 21:44:38 +02:00
2017-06-22 23:52:27 +02:00
return response ;
}
2017-06-23 20:51:02 +02:00
2019-09-19 23:51:18 +02:00
throw new Error ( ` Could not execute request after multiple attempts: ${ method } ${ url } ` ) ;
2017-06-22 21:44:38 +02:00
}
2021-01-20 17:49:02 +02:00
setAccountProperties ( accountProperties : any ) {
2020-08-08 01:35:30 +02:00
this . accountProperties_ = accountProperties ;
}
async execAccountPropertiesRequest() {
2020-08-28 10:46:41 +02:00
try {
const response = await this . exec ( 'GET' , 'https://graph.microsoft.com/v1.0/me/drive' ) ;
2020-08-08 01:35:30 +02:00
const data = await response . json ( ) ;
const accountProperties = { accountType : data.driveType , driveId : data.id } ;
return accountProperties ;
2020-08-28 10:46:41 +02:00
} catch ( error ) {
throw new Error ( ` Could not retrieve account details (drive ID, Account type. Error code: ${ error . code } , Error message: ${ error . message } ` ) ;
2020-08-08 01:35:30 +02:00
}
}
2021-01-20 17:49:02 +02:00
async execJson ( method : string , path : string , query : any = null , data : any = null ) {
2020-03-14 01:46:14 +02:00
const response = await this . exec ( method , path , query , data ) ;
const errorResponseText = await response . text ( ) ;
2017-11-30 20:29:10 +02:00
try {
2020-03-14 01:46:14 +02:00
const output = JSON . parse ( errorResponseText ) ; // await response.json();
2017-11-30 20:29:10 +02:00
return output ;
} catch ( error ) {
2019-09-19 23:51:18 +02:00
error . message = ` OneDriveApi::execJson: Cannot parse JSON: ${ errorResponseText } ${ error . message } ` ;
2017-11-30 20:29:10 +02:00
throw error ;
2019-10-09 21:35:13 +02:00
// throw new Error('Cannot parse JSON: ' + text);
2017-11-30 20:29:10 +02:00
}
2017-06-22 21:44:38 +02:00
}
2021-01-20 17:49:02 +02:00
async execText ( method : string , path : string , query : any = null , data : any = null ) {
2020-03-14 01:46:14 +02:00
const response = await this . exec ( method , path , query , data ) ;
const output = await response . text ( ) ;
2017-06-22 21:44:38 +02:00
return output ;
}
2017-06-22 23:52:27 +02:00
async refreshAccessToken() {
2017-07-26 23:07:27 +02:00
if ( ! this . auth_ || ! this . auth_ . refresh_token ) {
this . setAuth ( null ) ;
2017-07-28 20:13:07 +02:00
throw new Error ( _ ( 'Cannot refresh token: authentication data is missing. Starting the synchronisation again may fix the problem.' ) ) ;
2017-07-26 23:07:27 +02:00
}
2017-06-22 23:52:27 +02:00
2021-01-20 17:49:02 +02:00
const body : any = { } ;
2020-10-28 17:50:34 +02:00
body [ 'client_id' ] = this . clientId ( ) ;
if ( ! this . isPublic ( ) ) body [ 'client_secret' ] = this . clientSecret ( ) ;
body [ 'refresh_token' ] = this . auth_ . refresh_token ;
body [ 'redirect_uri' ] = 'http://localhost:1917' ;
body [ 'grant_type' ] = 'refresh_token' ;
2017-06-22 23:52:27 +02:00
2020-10-28 17:50:34 +02:00
const response = await shim . fetch ( this . tokenBaseUrl ( ) , {
2017-06-22 23:52:27 +02:00
method : 'POST' ,
2020-10-28 17:50:34 +02:00
body : urlUtils.objectToQueryString ( body ) ,
headers : {
[ 'Content-Type' ] : 'application/x-www-form-urlencoded' ,
} ,
} ) ;
2017-06-22 23:52:27 +02:00
if ( ! response . ok ) {
2017-07-07 00:15:31 +02:00
this . setAuth ( null ) ;
2020-03-14 01:46:14 +02:00
const msg = await response . text ( ) ;
2019-09-19 23:51:18 +02:00
throw new Error ( ` ${ msg } : TOKEN: ${ this . auth_ } ` ) ;
2017-06-22 23:52:27 +02:00
}
2020-03-14 01:46:14 +02:00
const auth = await response . json ( ) ;
2017-07-07 00:15:31 +02:00
this . setAuth ( auth ) ;
2017-06-22 23:52:27 +02:00
}
2017-06-22 21:44:38 +02:00
}