2020-10-09 19:35:46 +02:00
const shim = require ( 'lib/shim' ) . default ;
2017-11-03 02:09:34 +02:00
const { stringify } = require ( 'query-string' ) ;
const { time } = require ( 'lib/time-utils.js' ) ;
2020-10-09 19:35:46 +02:00
const Logger = require ( 'lib/Logger' ) . default ;
const { _ } = require ( 'lib/locale' ) ;
2020-10-28 17:50:34 +02:00
const urlUtils = require ( 'lib/urlUtils.js' ) ;
2017-06-22 21:44:38 +02:00
class OneDriveApi {
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.
constructor ( clientId , clientSecret , isPublic ) {
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-09 17:47:05 +02:00
this . logger _ = new Logger ( ) ;
}
setLogger ( l ) {
this . logger _ = l ;
}
logger ( ) {
return this . logger _ ;
2017-06-23 20:51:02 +02:00
}
2017-07-07 00:15:31 +02:00
isPublic ( ) {
return this . isPublic _ ;
}
2017-06-23 20:51:02 +02:00
dispatch ( eventName , param ) {
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 ) ;
}
}
on ( eventName , callback ) {
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' ;
}
2017-07-06 20:58:01 +02:00
auth ( ) {
return this . auth _ ;
}
2017-06-22 23:52:27 +02:00
setAuth ( auth ) {
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
}
2017-06-22 21:44:38 +02:00
authCodeUrl ( redirectUri ) {
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
}
2017-07-07 00:15:31 +02:00
async execTokenRequest ( code , redirectUri ) {
2020-10-28 17:50:34 +02:00
const body = { } ;
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 ;
}
}
2017-06-29 20:03:16 +02:00
oneDriveErrorResponseToError ( errorResponse ) {
if ( ! errorResponse ) return new Error ( 'Undefined error' ) ;
if ( errorResponse . error ) {
2020-03-14 01:46:14 +02:00
const e = errorResponse . error ;
const output = 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 ) ) ;
}
}
2020-06-03 15:29:47 +02:00
async uploadChunk ( url , handle , options ) {
options = Object . assign ( { } , options ) ;
if ( ! options . method ) { options . method = 'POST' ; }
if ( ! options . headers ) { options . headers = { } ; }
if ( ! options . contentLength ) throw new Error ( ' uploadChunk: contentLength is missing' ) ;
const chunk = await shim . fsDriver ( ) . readFileChunk ( handle , options . contentLength ) ;
const Buffer = require ( 'buffer' ) . Buffer ;
const buffer = Buffer . from ( chunk , 'base64' ) ;
delete options . contentLength ;
options . body = buffer ;
const response = await shim . fetch ( url , options ) ;
return response ;
}
async uploadBigFile ( url , options ) {
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 ;
// uploading file in 7.5 MiB-Fragments (except the last one) because this is the mean of 5 and 10 Mib which are the recommended lower and upper limits.
// https://docs.microsoft.com/de-de/onedrive/developer/rest-api/api/driveitem_createuploadsession?view=odsp-graph-online#best-practices
const chunkSize = 7.5 * 1024 * 1024 ;
const fileSize = ( await shim . fsDriver ( ) . stat ( options . path ) ) . size ;
const numberOfChunks = Math . ceil ( fileSize / chunkSize ) ;
const handle = await shim . fsDriver ( ) . open ( options . path , 'r' ) ;
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
endByte = fileSize - 1 ;
contentLength = fileSize - ( ( numberOfChunks - 1 ) * chunkSize ) ;
} else {
endByte = ( i + 1 ) * chunkSize - 1 ;
contentLength = chunkSize ;
}
this . logger ( ) . debug ( ` ${ options . path } : Uploading File Fragment ${ ( startByte / 1048576 ) . toFixed ( 2 ) } - ${ ( endByte / 1048576 ) . toFixed ( 2 ) } from ${ ( fileSize / 1048576 ) . toFixed ( 2 ) } Mbit ... ` ) ;
const headers = {
'Content-Length' : contentLength ,
'Content-Range' : ` bytes ${ startByte } - ${ endByte } / ${ fileSize } ` ,
'Content-Type' : 'application/octet-stream; charset=utf-8' ,
} ;
const response = await this . uploadChunk ( uploadUrl , handle , { contentLength : contentLength , method : 'PUT' , headers : headers } ) ;
if ( ! response . ok ) {
return response ;
}
}
return { ok : true } ;
} catch ( error ) {
this . logger ( ) . error ( 'Got unhandled error:' , error ? error . code : '' , error ? error . message : '' , error ) ;
throw error ;
} finally {
await shim . fsDriver ( ) . close ( handle ) ;
}
}
}
2017-06-22 21:44:38 +02:00
async exec ( method , path , query = null , data = null , options = 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
2017-07-06 23:30:45 +02:00
let response = null ;
2017-07-13 00:32:08 +02:00
try {
2017-08-01 23:40:14 +02:00
if ( options . source == 'file' && ( method == 'POST' || method == 'PUT' ) ) {
2020-06-03 15:29:47 +02:00
response = path . includes ( '/createUploadSession' ) ? await this . uploadBigFile ( url , options ) : 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 ) {
2017-10-22 14:45:56 +02:00
this . 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 ;
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 } ` ;
2017-11-30 20:29:10 +02:00
throw error ;
}
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' ) {
2017-07-09 17:47:05 +02:00
this . 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' }
2019-09-19 23:51:18 +02:00
this . logger ( ) . info ( ` Got error below - retrying ( ${ i } )... ` ) ;
2017-07-30 22:22:57 +02:00
this . logger ( ) . info ( error ) ;
2017-07-13 20:09:47 +02:00
await time . sleep ( ( i + 1 ) * 3 ) ;
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 ...
2019-09-19 23:51:18 +02:00
this . logger ( ) . info ( ` Got error below - retrying ( ${ i } )... ` ) ;
2017-12-05 01:01:22 +02:00
this . logger ( ) . info ( error ) ;
await time . sleep ( ( i + 1 ) * 3 ) ;
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
}
2020-08-08 01:35:30 +02:00
setAccountProperties ( accountProperties ) {
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
}
}
2017-06-22 21:44:38 +02:00
async execJson ( method , path , query , data ) {
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
}
async execText ( method , path , query , data ) {
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
2020-10-28 17:50:34 +02:00
const body = { } ;
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
}
2019-07-29 15:43:53 +02:00
module . exports = { OneDriveApi } ;