import { CryptoProvider, PublicClientApplication } from "@azure/msal-node"; import type { AuthenticationProvider } from "@microsoft/microsoft-graph-client"; import type { DriveItem, FileSystemInfo, UploadSession, User, } from "@microsoft/microsoft-graph-types"; import cloneDeep from "lodash/cloneDeep"; import { request, requestUrl } from "obsidian"; import { COMMAND_CALLBACK_ONEDRIVE, DEFAULT_CONTENT_TYPE, type Entity, OAUTH2_FORCE_EXPIRE_MILLISECONDS, type OnedriveConfig, } from "./baseTypes"; import { VALID_REQURL } from "./baseTypesObs"; import { FakeFs } from "./fsAll"; import { bufferToArrayBuffer } from "./misc"; const SCOPES = ["User.Read", "Files.ReadWrite.AppFolder", "offline_access"]; const REDIRECT_URI = `obsidian://${COMMAND_CALLBACK_ONEDRIVE}`; export const DEFAULT_ONEDRIVE_CONFIG: OnedriveConfig = { accessToken: "", clientID: process.env.DEFAULT_ONEDRIVE_CLIENT_ID ?? "", authority: process.env.DEFAULT_ONEDRIVE_AUTHORITY ?? "", refreshToken: "", accessTokenExpiresInSeconds: 0, accessTokenExpiresAtTime: 0, deltaLink: "", username: "", credentialsShouldBeDeletedAtTime: 0, }; //////////////////////////////////////////////////////////////////////////////// // Onedrive authorization using PKCE //////////////////////////////////////////////////////////////////////////////// export async function getAuthUrlAndVerifier( clientID: string, authority: string ) { const cryptoProvider = new CryptoProvider(); const { verifier, challenge } = await cryptoProvider.generatePkceCodes(); const pkceCodes = { challengeMethod: "S256", // Use SHA256 Algorithm verifier: verifier, challenge: challenge, }; const authCodeUrlParams = { redirectUri: REDIRECT_URI, scopes: SCOPES, codeChallenge: pkceCodes.challenge, // PKCE Code Challenge codeChallengeMethod: pkceCodes.challengeMethod, // PKCE Code Challenge Method }; 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: string, authority: string, authCode: string, verifier: string, errorCallBack: any ) => { // // original code snippets for references // const authResponse = await pca.acquireTokenByCode({ // redirectUri: REDIRECT_URI, // scopes: SCOPES, // code: authCode, // codeVerifier: verifier, // PKCE Code Verifier // }); // console.info('authResponse') // console.info(authResponse) // return authResponse; // Because of the CORS problem, // we need to construct raw request using Obsidian request, // instead of using msal // 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 try { 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); // console.info(rsp2); if (rsp2.error !== undefined) { return rsp2 as AccessCodeResponseFailedType; } else { return rsp2 as AccessCodeResponseSuccessfulType; } } catch (e) { console.error(e); await errorCallBack(e); } }; export const sendRefreshTokenReq = async ( clientID: string, authority: string, refreshToken: string ) => { // also use Obsidian request to bypass CORS issue. try { 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); // console.info(rsp2); if (rsp2.error !== undefined) { return rsp2 as AccessCodeResponseFailedType; } else { return rsp2 as AccessCodeResponseSuccessfulType; } } catch (e) { console.error(e); throw e; } }; export const setConfigBySuccessfullAuthInplace = async ( config: OnedriveConfig, authRes: AccessCodeResponseSuccessfulType, saveUpdatedConfigFunc: () => Promise | undefined ) => { console.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!; // manually set it expired after 80 days; config.credentialsShouldBeDeletedAtTime = Date.now() + OAUTH2_FORCE_EXPIRE_MILLISECONDS; if (saveUpdatedConfigFunc !== undefined) { await saveUpdatedConfigFunc(); } console.info("finish updating local info of Onedrive token"); }; //////////////////////////////////////////////////////////////////////////////// // Other usual common methods //////////////////////////////////////////////////////////////////////////////// const getOnedrivePath = (fileOrFolderPath: string, remoteBaseDir: string) => { // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/concepts/special-folders-appfolder?view=odsp-graph-online const prefix = `/drive/special/approot:/${remoteBaseDir}`; let key = fileOrFolderPath; if (fileOrFolderPath === "/" || fileOrFolderPath === "") { // special return prefix; } if (key.endsWith("/")) { key = key.slice(0, key.length - 1); } if (key.startsWith("/")) { console.warn(`why the path ${key} starts with '/'? but we just go on.`); key = `${prefix}${key}`; } else { key = `${prefix}/${key}`; } return key; }; const constructFromDriveItemToEntityError = (x: DriveItem) => { return `parentPath="${ x.parentReference?.path ?? "(no parentReference or path)" }", selfName="${x.name}"`; }; const fromDriveItemToEntity = (x: DriveItem, remoteBaseDir: string): Entity => { let key = ""; // possible prefix: // pure english: /drive/root:/Apps/remotely-save/${remoteBaseDir} // or localized, e.g.: /drive/root:/应用/remotely-save/${remoteBaseDir} const FIRST_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/remotely-save\//g; // why?? /drive/root:/Apps/Graph const FIFTH_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/Graph\//g; // or the root is absolute path /Livefolders, // e.g.: /Livefolders/应用/remotely-save/${remoteBaseDir} const SECOND_COMMON_PREFIX_REGEX = /^\/Livefolders\/[^\/]+\/remotely-save\//g; // another report, why??? // /drive/root:/something/app/remotely-save/${remoteBaseDir} const THIRD_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/app\/remotely-save\//g; // another possibile prefix const FOURTH_COMMON_PREFIX_RAW = `/drive/items/`; if ( x.parentReference === undefined || x.parentReference === null || x.parentReference.path === undefined || x.parentReference.path === null ) { throw Error("x.parentReference.path is undefinded or null"); } const fullPathOriginal = `${x.parentReference.path}/${x.name}`; const matchFirstPrefixRes = fullPathOriginal.match(FIRST_COMMON_PREFIX_REGEX); const matchFifthPrefixRes = fullPathOriginal.match(FIFTH_COMMON_PREFIX_REGEX); const matchSecondPrefixRes = fullPathOriginal.match( SECOND_COMMON_PREFIX_REGEX ); const matchThirdPrefixRes = fullPathOriginal.match(THIRD_COMMON_PREFIX_REGEX); if ( matchFirstPrefixRes !== null && fullPathOriginal.startsWith(`${matchFirstPrefixRes[0]}${remoteBaseDir}`) ) { const foundPrefix = `${matchFirstPrefixRes[0]}${remoteBaseDir}`; key = fullPathOriginal.substring(foundPrefix.length + 1); } else if ( matchFifthPrefixRes !== null && fullPathOriginal.startsWith(`${matchFifthPrefixRes[0]}${remoteBaseDir}`) ) { const foundPrefix = `${matchFifthPrefixRes[0]}${remoteBaseDir}`; key = fullPathOriginal.substring(foundPrefix.length + 1); } else if ( matchSecondPrefixRes !== null && fullPathOriginal.startsWith(`${matchSecondPrefixRes[0]}${remoteBaseDir}`) ) { const foundPrefix = `${matchSecondPrefixRes[0]}${remoteBaseDir}`; key = fullPathOriginal.substring(foundPrefix.length + 1); } else if ( matchThirdPrefixRes !== null && fullPathOriginal.startsWith(`${matchThirdPrefixRes[0]}${remoteBaseDir}`) ) { const foundPrefix = `${matchThirdPrefixRes[0]}${remoteBaseDir}`; key = fullPathOriginal.substring(foundPrefix.length + 1); } else if (x.parentReference.path.startsWith(FOURTH_COMMON_PREFIX_RAW)) { // it's something like // /drive/items/!:/${remoteBaseDir}/ // with uri encoded! if (x.name === undefined || x.name === null) { throw Error( `OneDrive item no name variable while matching ${FOURTH_COMMON_PREFIX_RAW}` ); } const parPath = decodeURIComponent(x.parentReference.path); key = parPath.substring(parPath.indexOf(":") + 1); if (key.startsWith(`/${remoteBaseDir}/`)) { key = key.substring(`/${remoteBaseDir}/`.length); key = `${key}/${x.name}`; } else if (key === `/${remoteBaseDir}`) { key = x.name; } else { throw Error( `we meet file/folder and do not know how to deal with it:\n${constructFromDriveItemToEntityError( x )}` ); } } else { throw Error( `we meet file/folder and do not know how to deal with it:\n${constructFromDriveItemToEntityError( x )}` ); } const isFolder = "folder" in x; if (isFolder) { key = `${key}/`; } const mtimeSvr = Date.parse(x?.fileSystemInfo!.lastModifiedDateTime!); const mtimeCli = Date.parse(x?.fileSystemInfo!.lastModifiedDateTime!); return { key: key, keyRaw: key, mtimeSvr: mtimeSvr, mtimeCli: mtimeCli, size: isFolder ? 0 : x.size!, sizeRaw: isFolder ? 0 : x.size!, // hash: ?? // TODO }; }; //////////////////////////////////////////////////////////////////////////////// // The client. //////////////////////////////////////////////////////////////////////////////// // to adapt to the required interface class MyAuthProvider implements AuthenticationProvider { onedriveConfig: OnedriveConfig; saveUpdatedConfigFunc: () => Promise; constructor( onedriveConfig: OnedriveConfig, saveUpdatedConfigFunc: () => Promise ) { this.onedriveConfig = onedriveConfig; this.saveUpdatedConfigFunc = saveUpdatedConfigFunc; } async getAccessToken() { 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 { // use refreshToken to refresh 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(); console.info("Onedrive accessToken updated"); return this.onedriveConfig.accessToken; } } } /** * to export the settings in qrcode, * we want to "trim" or "shrink" the settings * @param onedriveConfig */ export const getShrinkedSettings = (onedriveConfig: OnedriveConfig) => { const config = cloneDeep(onedriveConfig); config.accessToken = "x"; config.accessTokenExpiresInSeconds = 1; config.accessTokenExpiresAtTime = 1; return config; }; export class FakeFsOnedrive extends FakeFs { kind: "onedrive"; onedriveConfig: OnedriveConfig; remoteBaseDir: string; vaultFolderExists: boolean; authGetter: MyAuthProvider; saveUpdatedConfigFunc: () => Promise; foldersCreatedBefore: Set; constructor( onedriveConfig: OnedriveConfig, vaultName: string, saveUpdatedConfigFunc: () => Promise ) { super(); this.kind = "onedrive"; this.onedriveConfig = onedriveConfig; this.remoteBaseDir = this.onedriveConfig.remoteBaseDir || vaultName || ""; this.vaultFolderExists = false; this.saveUpdatedConfigFunc = saveUpdatedConfigFunc; this.authGetter = new MyAuthProvider(onedriveConfig, saveUpdatedConfigFunc); this.foldersCreatedBefore = new Set(); } async _init() { // check token if ( this.onedriveConfig.accessToken === "" || this.onedriveConfig.refreshToken === "" ) { throw Error("The user has not manually auth yet."); } // check vault folder // console.info(`checking remote has folder /${this.remoteBaseDir}`); if (this.vaultFolderExists) { // console.info(`already checked, /${this.remoteBaseDir} exist before`) } else { const k = await this._getJson("/drive/special/approot/children"); // console.debug(k); this.vaultFolderExists = (k.value as DriveItem[]).filter((x) => x.name === this.remoteBaseDir) .length > 0; if (!this.vaultFolderExists) { console.info(`remote does not have folder /${this.remoteBaseDir}`); await this._postJson("/drive/special/approot/children", { name: `${this.remoteBaseDir}`, folder: {}, "@microsoft.graph.conflictBehavior": "replace", }); console.info(`remote folder /${this.remoteBaseDir} created`); this.vaultFolderExists = true; } else { // console.info(`remote folder /${this.remoteBaseDir} exists`); } } } _buildUrl(pathFragOrig: string) { const API_PREFIX = "https://graph.microsoft.com/v1.0"; let theUrl = ""; if ( pathFragOrig.startsWith("http://") || pathFragOrig.startsWith("https://") ) { theUrl = pathFragOrig; } else { const pathFrag = encodeURI(pathFragOrig); theUrl = `${API_PREFIX}${pathFrag}`; } // we want to support file name with hash # // because every url we construct here do not contain the # symbol // thus it should be safe to directly replace the character theUrl = theUrl.replace(/#/g, "%23"); // console.debug(`building url: [${pathFragOrig}] => [${theUrl}]`) return theUrl; } async _getJson(pathFragOrig: string) { const theUrl = this._buildUrl(pathFragOrig); console.debug(`getJson, theUrl=${theUrl}`); return JSON.parse( await request({ url: theUrl, method: "GET", contentType: "application/json", headers: { Authorization: `Bearer ${await this.authGetter.getAccessToken()}`, "Cache-Control": "no-cache", }, }) ); } async _postJson(pathFragOrig: string, payload: any) { const theUrl = this._buildUrl(pathFragOrig); console.debug(`postJson, theUrl=${theUrl}`); return JSON.parse( await request({ url: theUrl, method: "POST", contentType: "application/json", body: JSON.stringify(payload), headers: { Authorization: `Bearer ${await this.authGetter.getAccessToken()}`, }, }) ); } async _patchJson(pathFragOrig: string, payload: any) { const theUrl = this._buildUrl(pathFragOrig); console.debug(`patchJson, theUrl=${theUrl}`); return JSON.parse( await request({ url: theUrl, method: "PATCH", contentType: "application/json", body: JSON.stringify(payload), headers: { Authorization: `Bearer ${await this.authGetter.getAccessToken()}`, }, }) ); } async _deleteJson(pathFragOrig: string) { const theUrl = this._buildUrl(pathFragOrig); console.debug(`deleteJson, theUrl=${theUrl}`); if (VALID_REQURL) { await requestUrl({ url: theUrl, method: "DELETE", headers: { Authorization: `Bearer ${await this.authGetter.getAccessToken()}`, }, }); } else { await fetch(theUrl, { method: "DELETE", headers: { Authorization: `Bearer ${await this.authGetter.getAccessToken()}`, }, }); } } async _putArrayBuffer(pathFragOrig: string, payload: ArrayBuffer) { const theUrl = this._buildUrl(pathFragOrig); console.debug(`putArrayBuffer, theUrl=${theUrl}`); // TODO: // 20220401: On Android, requestUrl has issue that text becomes base64. // Use fetch everywhere instead! // biome-ignore lint/correctness/noConstantCondition: hard code if (false /*VALID_REQURL*/) { const res = await requestUrl({ url: theUrl, method: "PUT", body: payload, contentType: DEFAULT_CONTENT_TYPE, headers: { "Content-Type": DEFAULT_CONTENT_TYPE, Authorization: `Bearer ${await this.authGetter.getAccessToken()}`, }, }); return res.json as DriveItem | UploadSession; } else { const res = await fetch(theUrl, { method: "PUT", body: payload, headers: { "Content-Type": DEFAULT_CONTENT_TYPE, Authorization: `Bearer ${await this.authGetter.getAccessToken()}`, }, }); return (await res.json()) as DriveItem | UploadSession; } } /** * A specialized function to upload large files by parts * @param pathFragOrig * @param payload * @param rangeMin * @param rangeEnd the end, exclusive * @param size */ async _putUint8ArrayByRange( pathFragOrig: string, payload: Uint8Array, rangeStart: number, rangeEnd: number, size: number ) { const theUrl = this._buildUrl(pathFragOrig); console.debug( `putUint8ArrayByRange, theUrl=${theUrl}, range=${rangeStart}-${ rangeEnd - 1 }, len=${rangeEnd - rangeStart}, size=${size}` ); // NO AUTH HEADER here! // TODO: // 20220401: On Android, requestUrl has issue that text becomes base64. // Use fetch everywhere instead! // biome-ignore lint/correctness/noConstantCondition: hard code if (false /*VALID_REQURL*/) { const res = await requestUrl({ url: theUrl, method: "PUT", body: bufferToArrayBuffer(payload.subarray(rangeStart, rangeEnd)), contentType: DEFAULT_CONTENT_TYPE, headers: { // no "Content-Length" allowed here "Content-Range": `bytes ${rangeStart}-${rangeEnd - 1}/${size}`, /* "Cache-Control": "no-cache", not allowed here!!! */ }, }); return res.json as DriveItem | UploadSession; } else { const res = await fetch(theUrl, { method: "PUT", body: payload.subarray(rangeStart, rangeEnd), headers: { "Content-Length": `${rangeEnd - rangeStart}`, "Content-Range": `bytes ${rangeStart}-${rangeEnd - 1}/${size}`, "Content-Type": DEFAULT_CONTENT_TYPE, /* "Cache-Control": "no-cache", not allowed here!!! */ }, }); return (await res.json()) as DriveItem | UploadSession; } } /** * Use delta api to list all files and folders * https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delta?view=odsp-graph-online */ async walk(): Promise { await this._init(); const NEXT_LINK_KEY = "@odata.nextLink"; const DELTA_LINK_KEY = "@odata.deltaLink"; let res = await this._getJson( `/drive/special/approot:/${this.remoteBaseDir}:/delta` ); const driveItems = res.value as DriveItem[]; // console.debug(driveItems); while (NEXT_LINK_KEY in res) { res = await this._getJson(res[NEXT_LINK_KEY]); driveItems.push(...cloneDeep(res.value as DriveItem[])); } // lastly we should have delta link? if (DELTA_LINK_KEY in res) { this.onedriveConfig.deltaLink = res[DELTA_LINK_KEY]; await this.saveUpdatedConfigFunc(); } // unify everything to Entity const unifiedContents = driveItems .map((x) => fromDriveItemToEntity(x, this.remoteBaseDir)) .filter((x) => x.key !== "/"); return unifiedContents; } async stat(key: string): Promise { await this._init(); return await this._statFromRoot(getOnedrivePath(key, this.remoteBaseDir)); } async _statFromRoot(key: string): Promise { // console.info(`remotePath=${remotePath}`); const rsp = await this._getJson( `${key}?$select=cTag,eTag,fileSystemInfo,folder,file,name,parentReference,size` ); // console.info(rsp); const driveItem = rsp as DriveItem; const res = fromDriveItemToEntity(driveItem, this.remoteBaseDir); // console.info(res); return res; } async mkdir(key: string, mtime?: number, ctime?: number): Promise { if (!key.endsWith("/")) { throw Error(`you should not call mkdir on ${key}`); } await this._init(); const uploadFolder = getOnedrivePath(key, this.remoteBaseDir); console.debug(`mkdir uploadFolder=${uploadFolder}`); return await this._mkdirFromRoot(uploadFolder, mtime, ctime); } async _mkdirFromRoot( key: string, mtime?: number, ctime?: number ): Promise { // console.debug(`foldersCreatedBefore=${Array.from(this.foldersCreatedBefore)}`); if (this.foldersCreatedBefore.has(key)) { // created, pass // console.debug(`folder ${key} created.`) } else { // https://stackoverflow.com/questions/56479865/creating-nested-folders-in-one-go-onedrive-api // use PATCH to create folder recursively!!! const playload: any = { folder: {}, "@microsoft.graph.conflictBehavior": "replace", }; const fileSystemInfo: Record = {}; if (mtime !== undefined && mtime !== 0) { const mtimeStr = new Date(mtime).toISOString(); fileSystemInfo["lastModifiedDateTime"] = mtimeStr; } if (ctime !== undefined && ctime !== 0) { const ctimeStr = new Date(ctime).toISOString(); fileSystemInfo["createdDateTime"] = ctimeStr; } if (Object.keys(fileSystemInfo).length > 0) { playload["fileSystemInfo"] = fileSystemInfo; } await this._patchJson(key, playload); } const res = await this._statFromRoot(key); return res; } async writeFile( key: string, content: ArrayBuffer, mtime: number, ctime: number ): Promise { if (key.endsWith("/")) { throw Error(`you should not call writeFile on ${key}`); } await this._init(); const uploadFile = getOnedrivePath(key, this.remoteBaseDir); console.debug(`uploadFile=${uploadFile}`); return await this._writeFileFromRoot( uploadFile, content, mtime, ctime, key ); } async _writeFileFromRoot( key: string, content: ArrayBuffer, mtime: number, ctime: number, origKey: string ): Promise { if (content.byteLength === 0) { throw Error( `${origKey}: Empty file is not allowed in OneDrive, and please write something in it.` ); } const ctimeStr = new Date(ctime).toISOString(); const mtimeStr = new Date(mtime).toISOString(); // no need to create parent folders firstly, cool! // hard code range size const MIN_UNIT = 327680; // bytes in msft doc, about 0.32768 MB const RANGE_SIZE = MIN_UNIT * 20; // about 6.5536 MB const DIRECT_UPLOAD_MAX_SIZE = 1000 * 1000 * 4; // 4 Megabyte if (content.byteLength < DIRECT_UPLOAD_MAX_SIZE) { // directly using put! await this._putArrayBuffer( `${key}:/content?${new URLSearchParams({ "@microsoft.graph.conflictBehavior": "replace", })}`, content ); if (mtime !== 0 && ctime !== 0) { await this._patchJson(key, { fileSystemInfo: { lastModifiedDateTime: mtimeStr, createdDateTime: ctimeStr, } as FileSystemInfo, }); } } else { // upload large files! // ref: https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession?view=odsp-graph-online // 1. create uploadSession // uploadFile already starts with /drive/special/approot:/${remoteBaseDir} let playload: any = { item: { "@microsoft.graph.conflictBehavior": "replace", }, }; if (mtime !== 0 && ctime !== 0) { playload = { item: { "@microsoft.graph.conflictBehavior": "replace", // this is only possible using uploadSession fileSystemInfo: { lastModifiedDateTime: mtimeStr, createdDateTime: ctimeStr, } as FileSystemInfo, }, }; } const s: UploadSession = await this._postJson( `${key}:/createUploadSession`, playload ); const uploadUrl = s.uploadUrl!; console.debug("uploadSession = "); console.debug(s); // 2. upload by ranges // convert to uint8 const uint8 = new Uint8Array(content); // upload the ranges one by one let rangeStart = 0; while (rangeStart < uint8.byteLength) { await this._putUint8ArrayByRange( uploadUrl, uint8, rangeStart, Math.min(rangeStart + RANGE_SIZE, uint8.byteLength), uint8.byteLength ); rangeStart += RANGE_SIZE; } } const res = await this._statFromRoot(key); return res; } async readFile(key: string): Promise { await this._init(); if (key.endsWith("/")) { throw new Error(`you should not call readFile on folder ${key}`); } const downloadFile = getOnedrivePath(key, this.remoteBaseDir); return await this._readFileFromRoot(downloadFile); } async _readFileFromRoot(key: string): Promise { const rsp = await this._getJson( `${key}?$select=@microsoft.graph.downloadUrl` ); const downloadUrl: string = rsp["@microsoft.graph.downloadUrl"]; if (VALID_REQURL) { const content = ( await requestUrl({ url: downloadUrl, headers: { "Cache-Control": "no-cache" }, }) ).arrayBuffer; return content; } else { // cannot set no-cache here, will have cors error const content = await (await fetch(downloadUrl)).arrayBuffer(); return content; } } async rm(key: string): Promise { if (key === "" || key === "/") { return; } const remoteFileName = getOnedrivePath(key, this.remoteBaseDir); await this._init(); await this._deleteJson(remoteFileName); } async checkConnect(callbackFunc?: any): Promise { try { const k = await this.getUserDisplayName(); return k !== ""; } catch (err) { console.debug(err); callbackFunc?.(err); return false; } } async getUserDisplayName() { await this._init(); const res: User = await this._getJson("/me?$select=displayName"); return res.displayName || ""; } /** * * https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request * https://docs.microsoft.com/en-us/graph/api/user-revokesigninsessions * https://docs.microsoft.com/en-us/graph/api/user-invalidateallrefreshtokens */ async revokeAuth() { // await this._init(); // await this._postJson("/me/revokeSignInSessions", {}); throw new Error("Method not implemented."); } async getRevokeAddr() { return "https://account.live.com/consent/Manage"; } allowEmptyFile(): boolean { return false; } }