From 6552fd32d1024ffc54710204c71d63910e688ac6 Mon Sep 17 00:00:00 2001 From: fyears Date: Wed, 29 Dec 2021 00:35:46 +0800 Subject: [PATCH] basically working onedrive --- esbuild.config.mjs | 4 + package.json | 6 +- src/baseTypes.ts | 15 +- src/main.ts | 287 +++++++++++++++- src/misc.ts | 25 ++ src/remote.ts | 54 +++ src/remoteForOnedrive.ts | 691 +++++++++++++++++++++++++++++++++++++++ styles.css | 15 + webpack.config.js | 8 +- 9 files changed, 1088 insertions(+), 17 deletions(-) create mode 100644 src/remoteForOnedrive.ts diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 90cfa63..43e70dc 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -12,6 +12,8 @@ if you want to view the source, please visit the github repository of this plugi const prod = process.argv[2] === "production"; const DEFAULT_DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY || ""; +const DEFAULT_ONEDRIVE_CLIENT_ID = process.env.ONEDRIVE_CLIENT_ID || ""; +const DEFAULT_ONEDRIVE_AUTHORITY = process.env.ONEDRIVE_AUTHORITY || ""; esbuild .build({ @@ -40,6 +42,8 @@ esbuild outfile: "main.js", define: { "process.env.DEFAULT_DROPBOX_APP_KEY": `"${DEFAULT_DROPBOX_APP_KEY}"`, + "process.env.DEFAULT_ONEDRIVE_CLIENT_ID": `"${DEFAULT_ONEDRIVE_CLIENT_ID}"`, + "process.env.DEFAULT_ONEDRIVE_AUTHORITY": `"${DEFAULT_ONEDRIVE_AUTHORITY}"`, }, }) .catch(() => process.exit(1)); diff --git a/package.json b/package.json index 55eb1ae..a622987 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,15 @@ "browser": { "path": "path-browserify", "process": "process/browser", - "stream": "stream-browserify" + "stream": "stream-browserify", + "crypto": "crypto-browserify" }, "source": "main.ts", "keywords": [], "author": "", "license": "Apache-2.0", "devDependencies": { + "@microsoft/microsoft-graph-types": "^2.11.0", "@types/chai": "^4.2.22", "@types/chai-as-promised": "^7.1.4", "@types/jsdom": "^16.2.13", @@ -48,6 +50,8 @@ "@aws-sdk/client-s3": "^3.37.0", "@aws-sdk/lib-storage": "^3.40.1", "@aws-sdk/signature-v4-crt": "^3.37.0", + "@azure/msal-node": "^1.4.0", + "@microsoft/microsoft-graph-client": "^3.0.1", "acorn": "^8.5.0", "assert": "^2.0.0", "aws-crt": "^1.10.1", diff --git a/src/baseTypes.ts b/src/baseTypes.ts index 425477f..444a9cb 100644 --- a/src/baseTypes.ts +++ b/src/baseTypes.ts @@ -3,7 +3,7 @@ * To avoid circular dependency. */ -export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "dropbox"; +export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "dropbox" | "onedrive"; export interface S3Config { s3Endpoint: string; @@ -32,10 +32,22 @@ export interface WebdavConfig { authType: WebdavAuthType; } +export interface OnedriveConfig { + accessToken: string; + clientID: string; + authority: string; + refreshToken: string; + accessTokenExpiresInSeconds: number; + accessTokenExpiresAtTime: number; + deltaLink: string; + username: string; +} + export interface RemotelySavePluginSettings { s3: S3Config; webdav: WebdavConfig; dropbox: DropboxConfig; + onedrive: OnedriveConfig; password: string; serviceType: SUPPORTED_SERVICES_TYPE; } @@ -50,6 +62,7 @@ export interface RemoteItem { export const COMMAND_URI = "remotely-save"; export const COMMAND_CALLBACK = "remotely-save-cb"; +export const COMMAND_CALLBACK_ONEDRIVE = "remotely-save-cb-onedrive"; export interface UriParams { func?: string; diff --git a/src/main.ts b/src/main.ts index 2b06eca..ebc1d60 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,8 +31,8 @@ import { DEFAULT_S3_CONFIG } from "./remoteForS3"; import { DEFAULT_WEBDAV_CONFIG } from "./remoteForWebdav"; import { DEFAULT_DROPBOX_CONFIG, - getAuthUrlAndVerifier, - sendAuthReq, + getAuthUrlAndVerifier as getAuthUrlAndVerifierDropbox, + sendAuthReq as sendAuthReqDropbox, setConfigBySuccessfullAuthInplace, } from "./remoteForDropbox"; @@ -50,23 +50,49 @@ import type { import type { ProcessQrCodeResultType } from "./importExport"; import { exportQrCodeUri, importQrCodeUri } from "./importExport"; +import { + getAuthUrlAndVerifier as getAuthUrlAndVerifierOnedrive, + sendAuthReq as sendAuthReqOnedrive, + DEFAULT_ONEDRIVE_CONFIG, + WrappedOnedriveClient, + AccessCodeResponseSuccessfulType, +} from "./remoteForOnedrive"; + const DEFAULT_SETTINGS: RemotelySavePluginSettings = { s3: DEFAULT_S3_CONFIG, webdav: DEFAULT_WEBDAV_CONFIG, dropbox: DEFAULT_DROPBOX_CONFIG, + onedrive: DEFAULT_ONEDRIVE_CONFIG, password: "", serviceType: "s3", }; +interface OAuth2Info { + verifier?: string; + helperModal?: Modal; + authDiv?: HTMLElement; + revokeDiv?: HTMLElement; + revokeAuthSetting?: Setting; +} + export default class RemotelySavePlugin extends Plugin { settings: RemotelySavePluginSettings; // cm: CodeMirror.Editor; db: InternalDBs; syncStatus: SyncStatusType; + oauth2Info: OAuth2Info; async onload() { console.log(`loading plugin ${this.manifest.id}`); + this.oauth2Info = { + verifier: "", + helperModal: undefined, + authDiv: undefined, + revokeDiv: undefined, + revokeAuthSetting: undefined, + }; // init + await this.loadSettings(); await this.prepareDB(); @@ -110,6 +136,77 @@ export default class RemotelySavePlugin extends Plugin { ); } ); + this.registerObsidianProtocolHandler( + "remotely-save-cb-onedrive", + async (inputParams) => { + if (inputParams.code !== undefined) { + let rsp = await sendAuthReqOnedrive( + this.settings.onedrive.clientID, + this.settings.onedrive.authority, + inputParams.code, + this.oauth2Info.verifier + ); + + if ((rsp as any).error !== undefined) { + throw Error(`${JSON.stringify(rsp)}`); + } + + if (this.oauth2Info.helperModal !== undefined) { + this.oauth2Info.helperModal.contentEl.empty(); + this.oauth2Info.helperModal.contentEl.createEl("p", { + text: "Please wait, the plugin is trying to connect to Onedrive...", + }); + } + + rsp = rsp as AccessCodeResponseSuccessfulType; + this.settings.onedrive.accessToken = rsp.access_token; + this.settings.onedrive.accessTokenExpiresAtTime = + Date.now() + rsp.expires_in - 5 * 60 * 1000; + this.settings.onedrive.accessTokenExpiresInSeconds = rsp.expires_in; + this.settings.onedrive.refreshToken = rsp.refresh_token; + this.saveSettings(); + + const self = this; + const client = new RemoteClient( + "onedrive", + undefined, + undefined, + undefined, + this.settings.onedrive, + this.app.vault.getName(), + () => self.saveSettings() + ); + this.settings.onedrive.username = await client.getUser(); + this.saveSettings(); + + this.oauth2Info.verifier = ""; // reset it + this.oauth2Info.helperModal?.close(); // close it + this.oauth2Info.helperModal = undefined; + + this.oauth2Info.authDiv?.toggleClass( + "onedrive-auth-button-hide", + this.settings.onedrive.username !== "" + ); + this.oauth2Info.authDiv = undefined; + + this.oauth2Info.revokeAuthSetting?.setDesc( + `You've connected as user ${this.settings.dropbox.username}. If you want to disconnect, click this button.` + ); + this.oauth2Info.revokeAuthSetting = undefined; + this.oauth2Info.revokeDiv?.toggleClass( + "onedrive-revoke-auth-button-hide", + this.settings.onedrive.username === "" + ); + this.oauth2Info.revokeDiv = undefined; + } else { + throw Error( + `do not know how to deal with the callback: ${JSON.stringify( + inputParams + )}` + ); + } + } + ); this.addRibbonIcon("switch", "Remotely Save", async () => { if (this.syncStatus !== "idle") { @@ -134,6 +231,7 @@ export default class RemotelySavePlugin extends Plugin { this.settings.s3, this.settings.webdav, this.settings.dropbox, + this.settings.onedrive, this.app.vault.getName(), () => self.saveSettings() ); @@ -325,7 +423,7 @@ export class DropboxAuthModal extends Modal { async onOpen() { let { contentEl } = this; - const { authUrl, verifier } = await getAuthUrlAndVerifier( + const { authUrl, verifier } = await getAuthUrlAndVerifierDropbox( this.plugin.settings.dropbox.clientID ); @@ -358,7 +456,7 @@ export class DropboxAuthModal extends Modal { button.onClick(async () => { new Notice("Trying to connect to Dropbox"); try { - const authRes = await sendAuthReq( + const authRes = await sendAuthReqDropbox( this.plugin.settings.dropbox.clientID, verifier, authCode @@ -374,6 +472,7 @@ export class DropboxAuthModal extends Modal { undefined, undefined, this.plugin.settings.dropbox, + undefined, this.app.vault.getName(), () => self.plugin.saveSettings() ); @@ -407,6 +506,52 @@ export class DropboxAuthModal extends Modal { } } +export class OnedriveAuthModal extends Modal { + readonly plugin: RemotelySavePlugin; + readonly authDiv: HTMLDivElement; + readonly revokeAuthDiv: HTMLDivElement; + readonly revokeAuthSetting: Setting; + constructor( + app: App, + plugin: RemotelySavePlugin, + authDiv: HTMLDivElement, + revokeAuthDiv: HTMLDivElement, + revokeAuthSetting: Setting + ) { + super(app); + this.plugin = plugin; + this.authDiv = authDiv; + this.revokeAuthDiv = revokeAuthDiv; + this.revokeAuthSetting = revokeAuthSetting; + } + + async onOpen() { + let { contentEl } = this; + + const { authUrl, verifier } = await getAuthUrlAndVerifierOnedrive( + this.plugin.settings.onedrive.clientID, + this.plugin.settings.onedrive.authority + ); + this.plugin.oauth2Info.verifier = verifier; + + contentEl.createEl("p", { + text: "Visit the address in a browser, and follow the steps.", + }); + contentEl.createEl("p", { + text: "Finally you should be redirected to Obsidian.", + }); + contentEl.createEl("p").createEl("a", { + href: authUrl, + text: authUrl, + }); + } + + onClose() { + let { contentEl } = this; + contentEl.empty(); + } +} + export class ExportSettingsQrCodeModal extends Modal { plugin: RemotelySavePlugin; constructor(app: App, plugin: RemotelySavePlugin) { @@ -617,11 +762,7 @@ class RemotelySaveSettingTab extends PluginSettingTab { button.setButtonText("Check"); button.onClick(async () => { new Notice("Checking..."); - const client = new RemoteClient( - "s3", - this.plugin.settings.s3, - undefined - ); + const client = new RemoteClient("s3", this.plugin.settings.s3); const res = await client.checkConnectivity(); if (res) { new Notice("Great! The bucket can be accessed."); @@ -659,7 +800,7 @@ class RemotelySaveSettingTab extends PluginSettingTab { cls: "dropbox-revoke-auth-button-hide", }); - const revokeAuthSetting = new Setting(dropboxRevokeAuthDiv) + const dropboxRevokeAuthSetting = new Setting(dropboxRevokeAuthDiv) .setName("Revoke Auth") .setDesc( `You've connected as user ${this.plugin.settings.dropbox.username}. If you want to disconnect, click this button` @@ -674,6 +815,7 @@ class RemotelySaveSettingTab extends PluginSettingTab { undefined, undefined, this.plugin.settings.dropbox, + undefined, this.app.vault.getName(), () => self.plugin.saveSettings() ); @@ -709,7 +851,7 @@ class RemotelySaveSettingTab extends PluginSettingTab { this.plugin, dropboxAuthDiv, dropboxRevokeAuthDiv, - revokeAuthSetting + dropboxRevokeAuthSetting ).open(); }); }); @@ -736,6 +878,7 @@ class RemotelySaveSettingTab extends PluginSettingTab { undefined, undefined, this.plugin.settings.dropbox, + undefined, this.app.vault.getName(), () => self.plugin.saveSettings() ); @@ -749,6 +892,120 @@ class RemotelySaveSettingTab extends PluginSettingTab { }); }); + const onedriveDiv = containerEl.createEl("div", { cls: "onedrive-hide" }); + onedriveDiv.toggleClass( + "onedrive-hide", + this.plugin.settings.serviceType !== "onedrive" + ); + onedriveDiv.createEl("h2", { text: "Remote For Onedrive" }); + onedriveDiv.createEl("p", { + text: "Disclaimer: This app is NOT an official Onedrive product.", + cls: "onedrive-disclaimer", + }); + onedriveDiv.createEl("p", { + text: "Disclaimer: The information is stored in PLAIN TEXT locally. Other malicious/harmful/faulty plugins could read the info. If you see any unintentional access to your Onedrive, please immediately disconnect this app on https://microsoft.com/consent .", + cls: "onedrive-disclaimer", + }); + onedriveDiv.createEl("p", { + text: `We will create and sync inside the folder /Apps/${ + this.plugin.manifest.id + }/${this.app.vault.getName()} on your Onedrive.`, + }); + + const onedriveSelectAuthDiv = onedriveDiv.createDiv(); + const onedriveAuthDiv = onedriveSelectAuthDiv.createDiv({ + cls: "onedrive-auth-button-hide", + }); + const onedriveRevokeAuthDiv = onedriveSelectAuthDiv.createDiv({ + cls: "onedrive-revoke-auth-button-hide", + }); + + const onedriveRevokeAuthSetting = new Setting(onedriveRevokeAuthDiv) + .setName("Revoke Auth") + .setDesc( + `You've connected as user ${this.plugin.settings.onedrive.username}. If you want to disconnect, click this button` + ) + .addButton(async (button) => { + button.setButtonText("Revoke Auth"); + button.onClick(async () => { + try { + this.plugin.settings.onedrive = JSON.parse( + JSON.stringify(DEFAULT_ONEDRIVE_CONFIG) + ); + await this.plugin.saveSettings(); + onedriveAuthDiv.toggleClass( + "onedrive-auth-button-hide", + this.plugin.settings.onedrive.username !== "" + ); + onedriveRevokeAuthDiv.toggleClass( + "onedrive-revoke-auth-button-hide", + this.plugin.settings.onedrive.username === "" + ); + new Notice("Revoked!"); + } catch (err) { + console.error(err); + new Notice("Something goes wrong while revoking"); + } + }); + }); + + new Setting(onedriveAuthDiv) + .setName("Auth") + .setDesc("Auth") + .addButton(async (button) => { + button.setButtonText("Auth"); + button.onClick(async () => { + const modal = new OnedriveAuthModal( + this.app, + this.plugin, + onedriveAuthDiv, + onedriveRevokeAuthDiv, + onedriveRevokeAuthSetting + ); + this.plugin.oauth2Info.helperModal = modal; + this.plugin.oauth2Info.authDiv = onedriveAuthDiv; + this.plugin.oauth2Info.revokeDiv = onedriveRevokeAuthDiv; + this.plugin.oauth2Info.revokeAuthSetting = onedriveRevokeAuthSetting; + modal.open(); + }); + }); + + onedriveAuthDiv.toggleClass( + "onedrive-auth-button-hide", + this.plugin.settings.onedrive.username !== "" + ); + onedriveRevokeAuthDiv.toggleClass( + "onedrive-revoke-auth-button-hide", + this.plugin.settings.onedrive.username === "" + ); + + new Setting(onedriveDiv) + .setName("check connectivity") + .setDesc("check connectivity") + .addButton(async (button) => { + button.setButtonText("Check"); + button.onClick(async () => { + new Notice("Checking..."); + const self = this; + const client = new RemoteClient( + "onedrive", + undefined, + undefined, + undefined, + this.plugin.settings.onedrive, + this.app.vault.getName(), + () => self.plugin.saveSettings() + ); + + const res = await client.checkConnectivity(); + if (res) { + new Notice("Great! We can connect to Onedrive!"); + } else { + new Notice("We cannot connect to Onedrive."); + } + }); + }); + const webdavDiv = containerEl.createEl("div", { cls: "webdav-hide" }); webdavDiv.toggleClass( "webdav-hide", @@ -836,6 +1093,7 @@ class RemotelySaveSettingTab extends PluginSettingTab { undefined, this.plugin.settings.webdav, undefined, + undefined, this.app.vault.getName() ); const res = await client.checkConnectivity(); @@ -853,11 +1111,10 @@ class RemotelySaveSettingTab extends PluginSettingTab { .setName("Choose service") .setDesc("Choose a service.") .addDropdown(async (dropdown) => { - const currService = this.plugin.settings.serviceType; - dropdown.addOption("s3", "S3 (-compatible)"); dropdown.addOption("dropbox", "Dropbox"); dropdown.addOption("webdav", "Webdav"); + dropdown.addOption("onedrive", "OneDrive (alpha)"); dropdown .setValue(this.plugin.settings.serviceType) .onChange(async (val: SUPPORTED_SERVICES_TYPE) => { @@ -870,6 +1127,10 @@ class RemotelySaveSettingTab extends PluginSettingTab { "dropbox-hide", this.plugin.settings.serviceType !== "dropbox" ); + onedriveDiv.toggleClass( + "onedrive-hide", + this.plugin.settings.serviceType !== "onedrive" + ); webdavDiv.toggleClass( "webdav-hide", this.plugin.settings.serviceType !== "webdav" diff --git a/src/misc.ts b/src/misc.ts index a6b0e75..56bb0bb 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -163,3 +163,28 @@ export const extractSvgSub = (x: string, subEl: string = "rect") => { svg.setAttribute("viewbox", "0 0 10 10"); return svg.innerHTML; }; + +/** + * https://stackoverflow.com/questions/18230217 + * @param min + * @param max + * @returns + */ +export const getRandomIntInclusive = (min: number, max: number) => { + const randomBuffer = new Uint32Array(1); + window.crypto.getRandomValues(randomBuffer); + let randomNumber = randomBuffer[0] / (0xffffffff + 1); + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(randomNumber * (max - min + 1)) + min; +}; + +/** + * Random buffer + * @param byteLength + * @returns + */ +export const getRandomArrayBuffer = (byteLength: number) => { + const k = window.crypto.getRandomValues(new Uint8Array(byteLength)); + return bufferToArrayBuffer(k); +}; diff --git a/src/remote.ts b/src/remote.ts index d8fa343..63c5580 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -5,10 +5,12 @@ import type { S3Config, DropboxConfig, WebdavConfig, + OnedriveConfig, } from "./baseTypes"; import * as s3 from "./remoteForS3"; import * as webdav from "./remoteForWebdav"; import * as dropbox from "./remoteForDropbox"; +import * as onedrive from "./remoteForOnedrive"; export class RemoteClient { readonly serviceType: SUPPORTED_SERVICES_TYPE; @@ -18,12 +20,15 @@ export class RemoteClient { readonly webdavConfig?: WebdavConfig; readonly dropboxClient?: dropbox.WrappedDropboxClient; readonly dropboxConfig?: DropboxConfig; + readonly onedriveClient?: onedrive.WrappedOnedriveClient; + readonly onedriveConfig?: OnedriveConfig; constructor( serviceType: SUPPORTED_SERVICES_TYPE, s3Config?: S3Config, webdavConfig?: WebdavConfig, dropboxConfig?: DropboxConfig, + onedriveConfig?: OnedriveConfig, vaultName?: string, saveUpdatedConfigFunc?: () => Promise ) { @@ -51,6 +56,18 @@ export class RemoteClient { vaultName, saveUpdatedConfigFunc ); + } else if (serviceType === "onedrive") { + if (vaultName === undefined || saveUpdatedConfigFunc === undefined) { + throw Error( + "remember to provide vault name and callback while init onedrive client" + ); + } + this.onedriveConfig = onedriveConfig; + this.onedriveClient = onedrive.getOnedriveClient( + this.onedriveConfig, + vaultName, + saveUpdatedConfigFunc + ); } else { throw Error(`not supported service type ${this.serviceType}`); } @@ -67,6 +84,11 @@ export class RemoteClient { return await webdav.getRemoteMeta(this.webdavClient, fileOrFolderPath); } else if (this.serviceType === "dropbox") { return await dropbox.getRemoteMeta(this.dropboxClient, fileOrFolderPath); + } else if (this.serviceType === "onedrive") { + return await onedrive.getRemoteMeta( + this.onedriveClient, + fileOrFolderPath + ); } else { throw Error(`not supported service type ${this.serviceType}`); } @@ -109,6 +131,16 @@ export class RemoteClient { remoteEncryptedKey, foldersCreatedBefore ); + } else if (this.serviceType === "onedrive") { + return await onedrive.uploadToRemote( + this.onedriveClient, + fileOrFolderPath, + vault, + isRecursively, + password, + remoteEncryptedKey, + foldersCreatedBefore + ); } else { throw Error(`not supported service type ${this.serviceType}`); } @@ -121,6 +153,8 @@ export class RemoteClient { return await webdav.listFromRemote(this.webdavClient, prefix); } else if (this.serviceType === "dropbox") { return await dropbox.listFromRemote(this.dropboxClient, prefix); + } else if (this.serviceType === "onedrive") { + return await onedrive.listFromRemote(this.onedriveClient, prefix); } else { throw Error(`not supported service type ${this.serviceType}`); } @@ -161,6 +195,15 @@ export class RemoteClient { password, remoteEncryptedKey ); + } else if (this.serviceType === "onedrive") { + return await onedrive.downloadFromRemote( + this.onedriveClient, + fileOrFolderPath, + vault, + mtime, + password, + remoteEncryptedKey + ); } else { throw Error(`not supported service type ${this.serviceType}`); } @@ -193,6 +236,13 @@ export class RemoteClient { password, remoteEncryptedKey ); + } else if (this.serviceType === "onedrive") { + return await onedrive.deleteFromRemote( + this.onedriveClient, + fileOrFolderPath, + password, + remoteEncryptedKey + ); } else { throw Error(`not supported service type ${this.serviceType}`); } @@ -205,6 +255,8 @@ export class RemoteClient { return await webdav.checkConnectivity(this.webdavClient); } else if (this.serviceType === "dropbox") { return await dropbox.checkConnectivity(this.dropboxClient); + } else if (this.serviceType === "onedrive") { + return await onedrive.checkConnectivity(this.onedriveClient); } else { throw Error(`not supported service type ${this.serviceType}`); } @@ -213,6 +265,8 @@ export class RemoteClient { getUser = async () => { if (this.serviceType === "dropbox") { return await dropbox.getUserDisplayName(this.dropboxClient); + } else if (this.serviceType === "onedrive") { + return await onedrive.getUserDisplayName(this.onedriveClient); } else { throw Error(`not supported service type ${this.serviceType}`); } diff --git a/src/remoteForOnedrive.ts b/src/remoteForOnedrive.ts new file mode 100644 index 0000000..09068d4 --- /dev/null +++ b/src/remoteForOnedrive.ts @@ -0,0 +1,691 @@ +import * as path from "path"; +import { request, Vault } from "obsidian"; +import { PublicClientApplication, CryptoProvider } from "@azure/msal-node"; +import { COMMAND_CALLBACK_ONEDRIVE } from "./baseTypes"; +import type { OnedriveConfig, RemoteItem } from "./baseTypes"; + +import { + Client, + FileUpload, + UploadEventHandlers, + AuthenticationProvider, + AuthenticationProviderOptions, + Range, + LargeFileUploadSession, + LargeFileUploadTask, + LargeFileUploadTaskOptions, + UploadResult, +} from "@microsoft/microsoft-graph-client"; +import type { Drive, DriveItem, User } from "@microsoft/microsoft-graph-types"; +import { + getFolderLevels, + getPathFolder, + getRandomArrayBuffer, + getRandomIntInclusive, + mkdirpInVault, +} from "./misc"; +import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt"; + +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: "", +}; + +//////////////////////////////////////////////////////////////////////////////// +// 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 +) => { + // // original code snippets for references + // const authResponse = await pca.acquireTokenByCode({ + // redirectUri: REDIRECT_URI, + // scopes: SCOPES, + // code: authCode, + // codeVerifier: verifier, // PKCE Code Verifier + // }); + // console.log('authResponse') + // console.log(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 + 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.log(rsp2); + + if (rsp2.error !== undefined) { + return rsp2 as AccessCodeResponseFailedType; + } else { + return rsp2 as AccessCodeResponseSuccessfulType; + } +}; + +export const sendRefreshTokenReq = async ( + clientID: string, + authority: string, + refreshToken: string +) => { + // also use Obsidian request to bypass CORS issue. + 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.log(rsp2); + + if (rsp2.error !== undefined) { + return rsp2 as AccessCodeResponseFailedType; + } else { + return rsp2 as AccessCodeResponseSuccessfulType; + } +}; + +//////////////////////////////////////////////////////////////////////////////// +// Other usual common methods +//////////////////////////////////////////////////////////////////////////////// + +const getOnedrivePath = (fileOrFolderPath: string, vaultName: string) => { + // https://docs.microsoft.com/en-us/onedrive/developer/rest-api/concepts/special-folders-appfolder?view=odsp-graph-online + const prefix = `/drive/special/approot:/${vaultName}`; + if (fileOrFolderPath.startsWith(prefix)) { + // already transformed, return as is + return fileOrFolderPath; + } + + let key = fileOrFolderPath; + if (fileOrFolderPath === "/" || fileOrFolderPath === "") { + // special + return prefix; + } + if (key.endsWith("/")) { + key = key.slice(0, key.length - 1); + } + + key = `${prefix}/${key}`; + return key; +}; + +const getNormPath = (fileOrFolderPath: string, vaultName: string) => { + 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 fromDriveItemToRemoteItem = ( + x: DriveItem, + vaultName: string +): RemoteItem => { + let key = ""; + + const COMMON_PREFIX = `/drive/root:/Apps/remotely-save/${vaultName}`; + const COMMON_PREFIX_OTHERS = `/drive/items/`; + if (`${x.parentReference.path}/${x.name}`.startsWith(COMMON_PREFIX)) { + key = `${x.parentReference.path}/${x.name}`.substring( + COMMON_PREFIX.length + 1 + ); + } else if (x.parentReference.path.startsWith(COMMON_PREFIX_OTHERS)) { + // it's something like + // /drive/items/!:/${vaultName}/ + // with uri encoded! + 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${JSON.stringify( + x + )}` + ); + } + } else { + throw Error( + `we meet file/folder and do not know how to deal with it:\n${JSON.stringify( + 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 || "", + }; +}; + +// 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; + } + 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 { + // 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.log("Onedrive accessToken updated"); + return this.onedriveConfig.accessToken; + } + }; +} + +export class WrappedOnedriveClient { + onedriveConfig: OnedriveConfig; + vaultName: string; + client: Client; + vaultFolderExists: boolean; + saveUpdatedConfigFunc: () => Promise; + constructor( + onedriveConfig: OnedriveConfig, + vaultName: string, + saveUpdatedConfigFunc: () => Promise + ) { + this.onedriveConfig = onedriveConfig; + this.vaultName = vaultName; + this.vaultFolderExists = false; + this.saveUpdatedConfigFunc = saveUpdatedConfigFunc; + this.client = Client.initWithMiddleware({ + authProvider: new MyAuthProvider(onedriveConfig, saveUpdatedConfigFunc), + }); + } + + init = async () => { + // check token + if ( + this.onedriveConfig.accessToken === "" || + this.onedriveConfig.refreshToken === "" + ) { + throw Error("The user has not manually auth yet."); + } + + // check vault folder + // console.log(`checking remote has folder /${this.vaultName}`); + if (this.vaultFolderExists) { + // console.log(`already checked, /${this.vaultName} exist before`) + } else { + const k = await this.client.api("/drive/special/approot/children").get(); + // console.log(k); + this.vaultFolderExists = + (k.value as DriveItem[]).filter((x) => x.name === this.vaultName) + .length > 0; + if (!this.vaultFolderExists) { + console.log(`remote does not have folder /${this.vaultName}`); + await this.client.api("/drive/special/approot/children").post({ + name: `${this.vaultName}`, + folder: {}, + "@microsoft.graph.conflictBehavior": "replace", + }); + console.log(`remote folder /${this.vaultName} created`); + this.vaultFolderExists = true; + } else { + // console.log(`remote folder /${this.vaultName} exists`); + } + } + }; +} + +export const getOnedriveClient = ( + onedriveConfig: OnedriveConfig, + vaultName: string, + saveUpdatedConfigFunc: () => Promise +) => { + return new WrappedOnedriveClient( + onedriveConfig, + vaultName, + saveUpdatedConfigFunc + ); +}; + +/** + * 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 + * @param client + * @param prefix + */ +export const listFromRemote = async ( + client: WrappedOnedriveClient, + prefix?: string +) => { + 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(); + const driveItems = res.value as DriveItem[]; + + while (NEXT_LINK_KEY in res) { + res = await client.client.api(res[NEXT_LINK_KEY]).get(); + driveItems.push(...JSON.parse(JSON.stringify(res.value as DriveItem[]))); + } + + // lastly we should have delta link? + if (DELTA_LINK_KEY in res) { + client.onedriveConfig.deltaLink = res[DELTA_LINK_KEY]; + await client.saveUpdatedConfigFunc(); + } + + // unify everything to RemoteItem + const unifiedContents = driveItems + .map((x) => fromDriveItemToRemoteItem(x, client.vaultName)) + .filter((x) => x.key !== "/"); + return { + Contents: unifiedContents, + }; +}; + +export const getRemoteMeta = async ( + client: WrappedOnedriveClient, + fileOrFolderPath: string +) => { + await client.init(); + const remotePath = getOnedrivePath(fileOrFolderPath, client.vaultName); + // console.log(`remotePath=${remotePath}`); + const rsp = await client.client + .api(remotePath) + .select("cTag,eTag,fileSystemInfo,folder,file,name,parentReference,size") + .get(); + // console.log(rsp); + const driveItem = rsp as DriveItem; + const res = fromDriveItemToRemoteItem(driveItem, client.vaultName); + // console.log(res); + return res; +}; + +export const uploadToRemote = async ( + client: WrappedOnedriveClient, + fileOrFolderPath: string, + vault: Vault, + isRecursively: boolean = false, + password: string = "", + remoteEncryptedKey: string = "", + foldersCreatedBefore: Set | undefined = undefined +) => { + await client.init(); + + let uploadFile = fileOrFolderPath; + if (password !== "") { + uploadFile = remoteEncryptedKey; + } + uploadFile = getOnedrivePath(uploadFile, client.vaultName); + // console.log(`uploadFile=${uploadFile}`); + + const isFolder = fileOrFolderPath.endsWith("/"); + + if (isFolder && isRecursively) { + throw Error("upload function doesn't implement recursive function yet!"); + } else if (isFolder && !isRecursively) { + // folder + if (password === "") { + // if not encrypted, mkdir a remote folder + if (foldersCreatedBefore?.has(uploadFile)) { + // created, pass + } else { + // https://stackoverflow.com/questions/56479865/creating-nested-folders-in-one-go-onedrive-api + // use PATCH to create folder recursively!!! + await client.client.api(uploadFile).patch({ + folder: {}, + "@microsoft.graph.conflictBehavior": "replace", + }); + } + const res = await getRemoteMeta(client, uploadFile); + return res; + } else { + // if encrypted, + // upload a fake, random-size file + // with the encrypted file name + const byteLengthRandom = getRandomIntInclusive( + 1, + 65536 /* max allowed */ + ); + const arrBufRandom = await encryptArrayBuffer( + getRandomArrayBuffer(byteLengthRandom), + password + ); + + const uploadSession: LargeFileUploadSession = + 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?: Range) => { + // Handle progress event + // console.log( + // `uploading ${range.minValue}-${range.maxValue} of ${fileOrFolderPath}` + // ); + }, + } as UploadEventHandlers, + } as LargeFileUploadTaskOptions + ); + const uploadResult: UploadResult = await task.upload(); + // console.log(uploadResult) + const res = await getRemoteMeta(client, uploadFile); + return res; + } + } else { + // file + // we ignore isRecursively parameter here + const localContent = await vault.adapter.readBinary(fileOrFolderPath); + let remoteContent = localContent; + if (password !== "") { + remoteContent = await encryptArrayBuffer(localContent, password); + } + + // no need to create parent folders firstly, cool! + + // we need to customize the special root folder, + // so use LargeFileUploadTask instead of OneDriveLargeFileUploadTask + const progress = (range?: Range) => { + // Handle progress event + // console.log( + // `uploading ${range.minValue}-${range.maxValue} of ${fileOrFolderPath}` + // ); + }; + const uploadEventHandlers: UploadEventHandlers = { + progress: progress, + }; + const options: LargeFileUploadTaskOptions = { + rangeSize: 1024 * 1024, + uploadEventHandlers: uploadEventHandlers, + }; + const payload = { + item: { + "@microsoft.graph.conflictBehavior": "replace", + }, + }; + // uploadFile already starts with /drive/special/approot:/${vaultName} + const uploadSession: LargeFileUploadSession = + 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: UploadResult = await task.upload(); + // console.log(uploadResult) + const res = await getRemoteMeta(client, uploadFile); + return res; + } +}; + +const downloadFromRemoteRaw = async ( + client: WrappedOnedriveClient, + fileOrFolderPath: string +): Promise => { + await client.init(); + const key = getOnedrivePath(fileOrFolderPath, client.vaultName); + const rsp = await client.client + .api(key) + .select("@microsoft.graph.downloadUrl") + .get(); + const downloadUrl: string = rsp["@microsoft.graph.downloadUrl"]; + const content = await (await fetch(downloadUrl)).arrayBuffer(); + return content; +}; + +export const downloadFromRemote = async ( + client: WrappedOnedriveClient, + fileOrFolderPath: string, + vault: Vault, + mtime: number, + password: string = "", + remoteEncryptedKey: string = "" +) => { + await client.init(); + + const isFolder = fileOrFolderPath.endsWith("/"); + + await mkdirpInVault(fileOrFolderPath, vault); + + if (isFolder) { + // mkdirp locally is enough + // do nothing here + } 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: WrappedOnedriveClient, + fileOrFolderPath: string, + password: string = "", + remoteEncryptedKey: string = "" +) => { + 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: WrappedOnedriveClient) => { + try { + const k = await getUserDisplayName(client); + return k !== ""; + } catch (err) { + return false; + } +}; + +export const getUserDisplayName = async (client: WrappedOnedriveClient) => { + await client.init(); + const res: User = await client.client.api("/me").select("displayName").get(); + 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 + * @param client + */ +// export const revokeAuth = async (client: WrappedOnedriveClient) => { +// await client.init(); +// await client.client.api('/me/revokeSignInSessions').post(undefined); +// }; + +export const getRevokeAddr = async () => { + return "https://account.live.com/consent/Manage"; +}; diff --git a/styles.css b/styles.css index 10beb22..3609c6c 100644 --- a/styles.css +++ b/styles.css @@ -30,6 +30,21 @@ display: none; } +.onedrive-disclaimer { + font-weight: bold; +} +.onedrive-hide { + display: none; +} + +.onedrive-auth-button-hide { + display: none; +} + +.onedrive-revoke-auth-button-hide { + display: none; +} + .webdav-disclaimer { font-weight: bold; } diff --git a/webpack.config.js b/webpack.config.js index af4cf51..272c359 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,6 +4,8 @@ const webpack = require("webpack"); const TerserPlugin = require("terser-webpack-plugin"); const DEFAULT_DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY || ""; +const DEFAULT_ONEDRIVE_CLIENT_ID = process.env.ONEDRIVE_CLIENT_ID || ""; +const DEFAULT_ONEDRIVE_AUTHORITY = process.env.ONEDRIVE_AUTHORITY || ""; module.exports = { entry: "./src/main.ts", @@ -16,6 +18,8 @@ module.exports = { plugins: [ new webpack.DefinePlugin({ "process.env.DEFAULT_DROPBOX_APP_KEY": `"${DEFAULT_DROPBOX_APP_KEY}"`, + "process.env.DEFAULT_ONEDRIVE_CLIENT_ID": `"${DEFAULT_ONEDRIVE_CLIENT_ID}"`, + "process.env.DEFAULT_ONEDRIVE_AUTHORITY": `"${DEFAULT_ONEDRIVE_AUTHORITY}"`, }), // Work around for Buffer is undefined: // https://github.com/webpack/changelog-v5/issues/10 @@ -47,8 +51,8 @@ module.exports = { // buffer: require.resolve("buffer/"), // console: require.resolve("console-browserify"), // constants: require.resolve("constants-browserify"), - // crypto: require.resolve("crypto-browserify"), - crypto: false, + crypto: require.resolve("crypto-browserify"), + // crypto: false, // domain: require.resolve("domain-browser"), // events: require.resolve("events"), // http: require.resolve("stream-http"),