From 0a7de6588a5b022482dea6b752dfb2bb333c464d Mon Sep 17 00:00:00 2001 From: fyears Date: Mon, 29 Nov 2021 00:04:24 +0800 Subject: [PATCH] ugly way to refresh token --- src/main.ts | 27 +- src/remote.ts | 21 +- src/remoteForDropbox.ts | 605 +++++++++++++++++++++++----------------- 3 files changed, 383 insertions(+), 270 deletions(-) diff --git a/src/main.ts b/src/main.ts index 4fd9694..1c533ee 100644 --- a/src/main.ts +++ b/src/main.ts @@ -103,11 +103,13 @@ export default class RemotelySavePlugin extends Plugin { new Notice("2/6 Starting to fetch remote meta data."); this.syncStatus = "getting_remote_meta"; + const self = this; const client = new RemoteClient( this.settings.serviceType, this.settings.s3, this.settings.webdav, - this.settings.dropbox + this.settings.dropbox, + () => self.saveSettings() ); const remoteRsp = await client.listFromRemote(); // console.log(remoteRsp); @@ -336,11 +338,13 @@ export class DropboxAuthModal extends Modal { this.plugin.settings.dropbox, authRes ); + const self = this; const client = new RemoteClient( "dropbox", undefined, undefined, - this.plugin.settings.dropbox + this.plugin.settings.dropbox, + () => self.plugin.saveSettings() ); const username = await client.getUser(); this.plugin.settings.dropbox.username = username; @@ -359,7 +363,7 @@ export class DropboxAuthModal extends Modal { ); this.close(); } catch (err) { - console.log(err); + console.error(err); new Notice("Something goes wrong while connecting to Dropbox."); } }); @@ -607,16 +611,18 @@ class RemotelySaveSettingTab extends PluginSettingTab { button.setButtonText("Revoke Auth"); button.onClick(async () => { try { - console.log("settings "); - console.log(this.plugin.settings.dropbox); + const self = this; const client = new RemoteClient( "dropbox", undefined, undefined, - this.plugin.settings.dropbox + this.plugin.settings.dropbox, + () => self.plugin.saveSettings() ); await client.revokeAuth(); - this.plugin.settings.dropbox = { ...DEFAULT_DROPBOX_CONFIG }; + this.plugin.settings.dropbox = JSON.parse( + JSON.stringify(DEFAULT_DROPBOX_CONFIG) + ); await this.plugin.saveSettings(); dropboxAuthDiv.toggleClass( "dropbox-auth-button-hide", @@ -628,7 +634,7 @@ class RemotelySaveSettingTab extends PluginSettingTab { ); new Notice("Revoked!"); } catch (err) { - console.log(err); + console.error(err); new Notice("Something goes wrong while revoking"); } }); @@ -666,12 +672,15 @@ class RemotelySaveSettingTab extends PluginSettingTab { button.setButtonText("Check"); button.onClick(async () => { new Notice("Checking..."); + const self = this; const client = new RemoteClient( "dropbox", undefined, undefined, - this.plugin.settings.dropbox + this.plugin.settings.dropbox, + () => self.plugin.saveSettings() ); + const res = await client.checkConnectivity(); if (res) { new Notice("Great! We can connect to Dropbox!"); diff --git a/src/remote.ts b/src/remote.ts index 33a8d16..3300149 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -11,25 +11,34 @@ export class RemoteClient { readonly s3Config?: s3.S3Config; readonly webdavClient?: webdav.WebDAVClient; readonly webdavConfig?: webdav.WebdavConfig; - readonly dropboxClient?: dropbox.Dropbox; + readonly dropboxClient?: dropbox.WrappedDropboxClient; readonly dropboxConfig?: dropbox.DropboxConfig; constructor( serviceType: SUPPORTED_SERVICES_TYPE, s3Config?: s3.S3Config, webdavConfig?: webdav.WebdavConfig, - dropboxConfig?: dropbox.DropboxConfig + dropboxConfig?: dropbox.DropboxConfig, + saveUpdatedConfigFunc?: () => Promise ) { this.serviceType = serviceType; + // the client may modify the config inplace, + // so we use a ref not copy of config here if (serviceType === "s3") { - this.s3Config = { ...s3Config }; + this.s3Config = s3Config; this.s3Client = s3.getS3Client(this.s3Config); } else if (serviceType === "webdav") { - this.webdavConfig = { ...webdavConfig }; + this.webdavConfig = webdavConfig; this.webdavClient = webdav.getWebdavClient(this.webdavConfig); } else if (serviceType === "dropbox") { - this.dropboxConfig = { ...dropboxConfig }; - this.dropboxClient = dropbox.getDropboxClient(this.dropboxConfig); + if (saveUpdatedConfigFunc === undefined) { + throw Error("remember to provide callback while init dropbox client"); + } + this.dropboxConfig = dropboxConfig; + this.dropboxClient = dropbox.getDropboxClient( + this.dropboxConfig, + saveUpdatedConfigFunc + ); } else { throw Error(`not supported service type ${this.serviceType}`); } diff --git a/src/remoteForDropbox.ts b/src/remoteForDropbox.ts index a58e466..6327893 100644 --- a/src/remoteForDropbox.ts +++ b/src/remoteForDropbox.ts @@ -152,249 +152,10 @@ const fixLastModifiedTimeInplace = (allFilesFolders: RemoteItem[]) => { return allFilesFolders; }; -export const getDropboxClient = ( - accessTokenOrDropboxConfig: string | DropboxConfig -) => { - if (typeof accessTokenOrDropboxConfig === "string") { - return new Dropbox({ accessToken: accessTokenOrDropboxConfig }); - } - return new Dropbox({ - accessToken: accessTokenOrDropboxConfig.accessToken, - }); -}; - -export const getRemoteMeta = async (dbx: Dropbox, fileOrFolderPath: string) => { - if (fileOrFolderPath === "" || fileOrFolderPath === "/") { - // filesGetMetadata doesn't support root folder - // we instead try to list files - // if no error occurs, we ensemble a fake result. - const rsp = await dbx.filesListFolder({ - path: "", - recursive: false, // don't need to recursive here - }); - if (rsp.status !== 200) { - throw Error(JSON.stringify(rsp)); - } - return { - key: fileOrFolderPath, - lastModified: undefined, - size: 0, - remoteType: "dropbox", - etag: undefined, - } as RemoteItem; - } - - const key = getDropboxPath(fileOrFolderPath); - - const rsp = await dbx.filesGetMetadata({ - path: key, - }); - if (rsp.status !== 200) { - throw Error(JSON.stringify(rsp)); - } - return fromDropboxItemToRemoteItem(rsp.result); -}; - -export const uploadToRemote = async ( - dbx: Dropbox, - fileOrFolderPath: string, - vault: Vault, - isRecursively: boolean = false, - password: string = "", - remoteEncryptedKey: string = "", - foldersCreatedBefore: Set | undefined = undefined -) => { - let uploadFile = fileOrFolderPath; - if (password !== "") { - uploadFile = remoteEncryptedKey; - } - uploadFile = getDropboxPath(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 { - try { - await dbx.filesCreateFolderV2({ - path: uploadFile, - }); - foldersCreatedBefore?.add(uploadFile); - } catch (err) { - if (err.status === 409) { - // pass - foldersCreatedBefore?.add(uploadFile); - } else { - throw err; - } - } - } - const res = await getRemoteMeta(dbx, uploadFile); - return res; - } else { - // if encrypted, upload a fake file with the encrypted file name - await dbx.filesUpload({ - path: uploadFile, - contents: "", - }); - return await getRemoteMeta(dbx, uploadFile); - } - } else { - // file - // we ignore isRecursively parameter here - const localContent = await vault.adapter.readBinary(fileOrFolderPath); - let remoteContent = localContent; - if (password !== "") { - remoteContent = await encryptArrayBuffer(localContent, password); - } - // in dropbox, we don't need to create folders before uploading! cool! - // TODO: filesUploadSession for larger files (>=150 MB) - await dbx.filesUpload({ - path: uploadFile, - contents: remoteContent, - mode: { - ".tag": "overwrite", - }, - }); - // we want to mark that parent folders are created - if (foldersCreatedBefore !== undefined) { - const dirs = getFolderLevels(uploadFile).map(getDropboxPath); - for (const dir of dirs) { - foldersCreatedBefore?.add(dir); - } - } - return await getRemoteMeta(dbx, uploadFile); - } -}; - -export const listFromRemote = async (dbx: Dropbox, prefix?: string) => { - if (prefix !== undefined) { - throw Error("prefix not supported (yet)"); - } - const res = await dbx.filesListFolder({ path: "", recursive: true }); - if (res.status !== 200) { - throw Error(JSON.stringify(res)); - } - // console.log(res); - const contents = res.result.entries; - const unifiedContents = contents - .filter((x) => x[".tag"] !== "deleted") - .map(fromDropboxItemToRemoteItem); - fixLastModifiedTimeInplace(unifiedContents); - return { - Contents: unifiedContents, - }; -}; - -const downloadFromRemoteRaw = async ( - dbx: Dropbox, - fileOrFolderPath: string -) => { - const key = getDropboxPath(fileOrFolderPath); - const rsp = await dbx.filesDownload({ - path: key, - }); - if ((rsp.result as any).fileBlob !== undefined) { - // we get a Blob - const content = (rsp.result as any).fileBlob as Blob; - return await content.arrayBuffer(); - } else if ((rsp.result as any).fileBinary !== undefined) { - // we get a Buffer - const content = (rsp.result as any).fileBinary as Buffer; - return bufferToArrayBuffer(content); - } else { - throw Error(`unknown rsp from dropbox download: ${rsp}`); - } -}; - -export const downloadFromRemote = async ( - dbx: Dropbox, - fileOrFolderPath: string, - vault: Vault, - mtime: number, - password: string = "", - remoteEncryptedKey: string = "" -) => { - const isFolder = fileOrFolderPath.endsWith("/"); - - await mkdirpInVault(fileOrFolderPath, vault); - - // the file is always local file - // we need to encrypt it - - if (isFolder) { - // mkdirp locally is enough - // do nothing here - } else { - let downloadFile = fileOrFolderPath; - if (password !== "") { - downloadFile = remoteEncryptedKey; - } - downloadFile = getDropboxPath(downloadFile); - const remoteContent = await downloadFromRemoteRaw(dbx, downloadFile); - let localContent = remoteContent; - if (password !== "") { - localContent = await decryptArrayBuffer(remoteContent, password); - } - await vault.adapter.writeBinary(fileOrFolderPath, localContent, { - mtime: mtime, - }); - } -}; - -export const deleteFromRemote = async ( - dbx: Dropbox, - fileOrFolderPath: string, - password: string = "", - remoteEncryptedKey: string = "" -) => { - if (fileOrFolderPath === "/") { - return; - } - let remoteFileName = fileOrFolderPath; - if (password !== "") { - remoteFileName = remoteEncryptedKey; - } - remoteFileName = getDropboxPath(remoteFileName); - - try { - await dbx.filesDeleteV2({ - path: remoteFileName, - }); - } catch (err) { - console.error("some error while deleting"); - console.log(err); - } -}; - -export const checkConnectivity = async (dbx: Dropbox) => { - try { - const results = await getRemoteMeta(dbx, "/"); - if (results === undefined) { - return false; - } - return true; - } catch (err) { - return false; - } -}; - -export const getUserDisplayName = async (dbx: Dropbox) => { - const acct = await dbx.usersGetCurrentAccount(); - return acct.result.name.display_name; -}; - -/** - * Dropbox authorization using PKCE - * see https://dropbox.tech/developers/pkce--what-and-why- - * - */ +//////////////////////////////////////////////////////////////////////////////// +// Dropbox authorization using PKCE +// see https://dropbox.tech/developers/pkce--what-and-why- +//////////////////////////////////////////////////////////////////////////////// const specialBase64Encode = (str: Buffer) => { return str @@ -424,12 +185,12 @@ export const getAuthUrl = (appKey: string, challenge: string) => { export interface DropboxSuccessAuthRes { access_token: string; - token_type: string; - expires_in: number; - refresh_token: string; - scope: string; - uid: string; - account_id: string; + token_type: "bearer"; + expires_in: string; + refresh_token?: string; + scope?: string; + uid?: string; + account_id?: string; } export const sendAuthReq = async ( @@ -450,18 +211,352 @@ export const sendAuthReq = async ( return resp2; }; +export const sendRefreshTokenReq = async ( + appKey: string, + refreshToken: string +) => { + console.log("start auto getting refreshed Dropbox access token."); + const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: appKey, + }), + }); + const resp2 = (await resp1.json()) as DropboxSuccessAuthRes; + console.log("finish auto getting refreshed Dropbox access token."); + return resp2; +}; + export const setConfigBySuccessfullAuthInplace = ( config: DropboxConfig, authRes: DropboxSuccessAuthRes ) => { + console.log("start updating local info of Dropbox token"); + config.accessToken = authRes.access_token; - config.refreshToken = authRes.refresh_token; - config.accessTokenExpiresInSeconds = authRes.expires_in; + config.accessTokenExpiresInSeconds = parseInt(authRes.expires_in); config.accessTokenExpiresAtTime = - Date.now() + authRes.expires_in * 1000 - 10 * 1000; - config.accountID = authRes.account_id; + Date.now() + parseInt(authRes.expires_in) * 1000 - 10 * 1000; + + if (authRes.refresh_token !== undefined) { + config.refreshToken = authRes.refresh_token; + } + if (authRes.refresh_token !== undefined) { + config.accountID = authRes.account_id; + } + + console.log("finish updating local info of Dropbox token"); }; -export const revokeAuth = async (client: Dropbox) => { - await client.authTokenRevoke(); +//////////////////////////////////////////////////////////////////////////////// +// Other usual common methods +//////////////////////////////////////////////////////////////////////////////// + +export class WrappedDropboxClient { + dropboxConfig: DropboxConfig; + saveUpdatedConfigFunc: () => Promise; + dropbox: Dropbox; + constructor( + dropboxConfig: DropboxConfig, + saveUpdatedConfigFunc: () => Promise + ) { + this.dropboxConfig = dropboxConfig; + this.saveUpdatedConfigFunc = saveUpdatedConfigFunc; + } + + init = async () => { + if ( + this.dropboxConfig.accessToken === "" || + this.dropboxConfig.username === "" + ) { + throw Error("The user has not manually auth yet."); + } + const currentTs = Date.now(); + if (this.dropboxConfig.accessTokenExpiresAtTime > currentTs) { + this.dropbox = new Dropbox({ + accessToken: this.dropboxConfig.accessToken, + }); + } else { + if (this.dropboxConfig.refreshToken === "") { + throw Error( + "We need to automatically refresh token but none is stored." + ); + } + const resp = await sendRefreshTokenReq( + this.dropboxConfig.clientID, + this.dropboxConfig.refreshToken + ); + + setConfigBySuccessfullAuthInplace(this.dropboxConfig, resp); + await this.saveUpdatedConfigFunc(); + + this.dropbox = new Dropbox({ + accessToken: this.dropboxConfig.accessToken, + }); + } + return this.dropbox; + }; +} + +/** + * @param dropboxConfig + * @returns + */ +export const getDropboxClient = ( + dropboxConfig: DropboxConfig, + saveUpdatedConfigFunc: () => Promise +) => { + return new WrappedDropboxClient(dropboxConfig, saveUpdatedConfigFunc); +}; + +export const getRemoteMeta = async ( + client: WrappedDropboxClient, + fileOrFolderPath: string +) => { + await client.init(); + if (fileOrFolderPath === "" || fileOrFolderPath === "/") { + // filesGetMetadata doesn't support root folder + // we instead try to list files + // if no error occurs, we ensemble a fake result. + const rsp = await client.dropbox.filesListFolder({ + path: "", + recursive: false, // don't need to recursive here + }); + if (rsp.status !== 200) { + throw Error(JSON.stringify(rsp)); + } + return { + key: fileOrFolderPath, + lastModified: undefined, + size: 0, + remoteType: "dropbox", + etag: undefined, + } as RemoteItem; + } + + const key = getDropboxPath(fileOrFolderPath); + + const rsp = await client.dropbox.filesGetMetadata({ + path: key, + }); + if (rsp.status !== 200) { + throw Error(JSON.stringify(rsp)); + } + return fromDropboxItemToRemoteItem(rsp.result); +}; + +export const uploadToRemote = async ( + client: WrappedDropboxClient, + 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 = getDropboxPath(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 { + try { + await client.dropbox.filesCreateFolderV2({ + path: uploadFile, + }); + foldersCreatedBefore?.add(uploadFile); + } catch (err) { + if (err.status === 409) { + // pass + foldersCreatedBefore?.add(uploadFile); + } else { + throw err; + } + } + } + const res = await getRemoteMeta(client, uploadFile); + return res; + } else { + // if encrypted, upload a fake file with the encrypted file name + await client.dropbox.filesUpload({ + path: uploadFile, + contents: "", + }); + return await getRemoteMeta(client, uploadFile); + } + } else { + // file + // we ignore isRecursively parameter here + const localContent = await vault.adapter.readBinary(fileOrFolderPath); + let remoteContent = localContent; + if (password !== "") { + remoteContent = await encryptArrayBuffer(localContent, password); + } + // in dropbox, we don't need to create folders before uploading! cool! + // TODO: filesUploadSession for larger files (>=150 MB) + await client.dropbox.filesUpload({ + path: uploadFile, + contents: remoteContent, + mode: { + ".tag": "overwrite", + }, + }); + // we want to mark that parent folders are created + if (foldersCreatedBefore !== undefined) { + const dirs = getFolderLevels(uploadFile).map(getDropboxPath); + for (const dir of dirs) { + foldersCreatedBefore?.add(dir); + } + } + return await getRemoteMeta(client, uploadFile); + } +}; + +export const listFromRemote = async ( + client: WrappedDropboxClient, + prefix?: string +) => { + if (prefix !== undefined) { + throw Error("prefix not supported (yet)"); + } + await client.init(); + const res = await client.dropbox.filesListFolder({ + path: "", + recursive: true, + }); + if (res.status !== 200) { + throw Error(JSON.stringify(res)); + } + // console.log(res); + const contents = res.result.entries; + const unifiedContents = contents + .filter((x) => x[".tag"] !== "deleted") + .map(fromDropboxItemToRemoteItem); + fixLastModifiedTimeInplace(unifiedContents); + return { + Contents: unifiedContents, + }; +}; + +const downloadFromRemoteRaw = async ( + client: WrappedDropboxClient, + fileOrFolderPath: string +) => { + await client.init(); + const key = getDropboxPath(fileOrFolderPath); + const rsp = await client.dropbox.filesDownload({ + path: key, + }); + if ((rsp.result as any).fileBlob !== undefined) { + // we get a Blob + const content = (rsp.result as any).fileBlob as Blob; + return await content.arrayBuffer(); + } else if ((rsp.result as any).fileBinary !== undefined) { + // we get a Buffer + const content = (rsp.result as any).fileBinary as Buffer; + return bufferToArrayBuffer(content); + } else { + throw Error(`unknown rsp from dropbox download: ${rsp}`); + } +}; + +export const downloadFromRemote = async ( + client: WrappedDropboxClient, + fileOrFolderPath: string, + vault: Vault, + mtime: number, + password: string = "", + remoteEncryptedKey: string = "" +) => { + const isFolder = fileOrFolderPath.endsWith("/"); + + await mkdirpInVault(fileOrFolderPath, vault); + + // the file is always local file + // we need to encrypt it + + if (isFolder) { + // mkdirp locally is enough + // do nothing here + } else { + let downloadFile = fileOrFolderPath; + if (password !== "") { + downloadFile = remoteEncryptedKey; + } + downloadFile = getDropboxPath(downloadFile); + 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: WrappedDropboxClient, + fileOrFolderPath: string, + password: string = "", + remoteEncryptedKey: string = "" +) => { + if (fileOrFolderPath === "/") { + return; + } + let remoteFileName = fileOrFolderPath; + if (password !== "") { + remoteFileName = remoteEncryptedKey; + } + remoteFileName = getDropboxPath(remoteFileName); + + await client.init(); + try { + await client.dropbox.filesDeleteV2({ + path: remoteFileName, + }); + } catch (err) { + console.error("some error while deleting"); + console.error(err); + } +}; + +export const checkConnectivity = async (client: WrappedDropboxClient) => { + try { + const results = await getRemoteMeta(client, "/"); + if (results === undefined) { + return false; + } + return true; + } catch (err) { + console.error("dropbox connectivity error:"); + console.error(err); + return false; + } +}; + +export const getUserDisplayName = async (client: WrappedDropboxClient) => { + await client.init(); + const acct = await client.dropbox.usersGetCurrentAccount(); + return acct.result.name.display_name; +}; + +export const revokeAuth = async (client: WrappedDropboxClient) => { + await client.init(); + await client.dropbox.authTokenRevoke(); };