From d5c2f726f9dcffb350ae1e36d3d1f92961d6c233 Mon Sep 17 00:00:00 2001 From: fyears <1142836+fyears@users.noreply.github.com> Date: Sun, 9 Jun 2024 13:37:55 +0800 Subject: [PATCH] box --- .env.example.txt | 2 + .github/workflows/auto-build.yml | 2 + .github/workflows/release.yml | 2 + esbuild.config.mjs | 4 + package.json | 1 + pro/src/account.ts | 111 ++-- pro/src/baseTypesPro.ts | 36 +- pro/src/fsBox.ts | 927 +++++++++++++++++++++++++++++++ pro/src/fsGoogleDrive.ts | 1 + pro/src/langs/en.json | 40 ++ pro/src/langs/zh_cn.json | 40 ++ pro/src/langs/zh_tw.json | 40 ++ pro/src/oauth2.ts | 26 + pro/src/settingsBox.ts | 367 ++++++++++++ src/baseTypes.ts | 16 +- src/fsGetter.ts | 3 + src/importExport.ts | 3 + src/main.ts | 92 ++- src/misc.ts | 11 + src/settings.ts | 21 + src/sync.ts | 7 +- styles.css | 19 + tests/configPersist.test.ts | 3 + webpack.config.js | 4 + 24 files changed, 1702 insertions(+), 76 deletions(-) create mode 100644 pro/src/fsBox.ts create mode 100644 pro/src/oauth2.ts create mode 100644 pro/src/settingsBox.ts diff --git a/.env.example.txt b/.env.example.txt index 23b9542..0153a92 100644 --- a/.env.example.txt +++ b/.env.example.txt @@ -5,3 +5,5 @@ REMOTELYSAVE_WEBSITE=http://127.0.0.1:46683 REMOTELYSAVE_CLIENT_ID=cli-xxx GOOGLEDRIVE_CLIENT_ID=xxx.apps.googleusercontent.com GOOGLEDRIVE_CLIENT_SECRET=GOCSPX-sss +BOX_CLIENT_ID= +BOX_CLIENT_SECRET= diff --git a/.github/workflows/auto-build.yml b/.github/workflows/auto-build.yml index 4962c9b..39219c7 100644 --- a/.github/workflows/auto-build.yml +++ b/.github/workflows/auto-build.yml @@ -23,6 +23,8 @@ jobs: REMOTELYSAVE_CLIENT_ID: ${{secrets.REMOTELYSAVE_CLIENT_ID}} GOOGLEDRIVE_CLIENT_ID: ${{secrets.GOOGLEDRIVE_CLIENT_ID}} GOOGLEDRIVE_CLIENT_SECRET: ${{secrets.GOOGLEDRIVE_CLIENT_SECRET}} + BOX_CLIENT_ID: ${{secrets.BOX_CLIENT_ID}} + BOX_CLIENT_SECRET: ${{secrets.BOX_CLIENT_SECRET}} strategy: matrix: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 989ed77..aa18e82 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,8 @@ jobs: REMOTELYSAVE_CLIENT_ID: ${{secrets.REMOTELYSAVE_CLIENT_ID}} GOOGLEDRIVE_CLIENT_ID: ${{secrets.GOOGLEDRIVE_CLIENT_ID}} GOOGLEDRIVE_CLIENT_SECRET: ${{secrets.GOOGLEDRIVE_CLIENT_SECRET}} + BOX_CLIENT_ID: ${{secrets.BOX_CLIENT_ID}} + BOX_CLIENT_SECRET: ${{secrets.BOX_CLIENT_SECRET}} strategy: matrix: diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 9bb6f23..67c54d5 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -22,6 +22,8 @@ const DEFAULT_REMOTELYSAVE_CLIENT_ID = process.env.REMOTELYSAVE_CLIENT_ID || ""; const DEFAULT_GOOGLEDRIVE_CLIENT_ID = process.env.GOOGLEDRIVE_CLIENT_ID || ""; const DEFAULT_GOOGLEDRIVE_CLIENT_SECRET = process.env.GOOGLEDRIVE_CLIENT_SECRET || ""; +const DEFAULT_BOX_CLIENT_ID = process.env.BOX_CLIENT_ID || ""; +const DEFAULT_BOX_CLIENT_SECRET = process.env.BOX_CLIENT_SECRET || ""; esbuild .context({ @@ -61,6 +63,8 @@ esbuild "process.env.DEFAULT_REMOTELYSAVE_CLIENT_ID": `"${DEFAULT_REMOTELYSAVE_CLIENT_ID}"`, "process.env.DEFAULT_GOOGLEDRIVE_CLIENT_ID": `"${DEFAULT_GOOGLEDRIVE_CLIENT_ID}"`, "process.env.DEFAULT_GOOGLEDRIVE_CLIENT_SECRET": `"${DEFAULT_GOOGLEDRIVE_CLIENT_SECRET}"`, + "process.env.DEFAULT_BOX_CLIENT_ID": `"${DEFAULT_BOX_CLIENT_ID}"`, + "process.env.DEFAULT_BOX_CLIENT_SECRET": `"${DEFAULT_BOX_CLIENT_SECRET}"`, global: "window", "process.env.NODE_DEBUG": `undefined`, // ugly fix "process.env.DEBUG": `undefined`, // ugly fix diff --git a/package.json b/package.json index d4ccd2c..d4a94c3 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "aggregate-error": "^5.0.0", "assert": "^2.1.0", "aws-crt": "^1.21.2", + "box-typescript-sdk-gen": "^1.0.0", "buffer": "^6.0.3", "crypto-browserify": "^3.12.0", "dropbox": "^10.34.0", diff --git a/pro/src/account.ts b/pro/src/account.ts index dfe3774..7520c8c 100644 --- a/pro/src/account.ts +++ b/pro/src/account.ts @@ -1,5 +1,4 @@ import { nanoid } from "nanoid"; -import { base64url } from "rfc4648"; import { OAUTH2_FORCE_EXPIRE_MILLISECONDS, type RemotelySavePluginSettings, @@ -12,6 +11,7 @@ import { PRO_WEBSITE, type ProConfig, } from "./baseTypesPro"; +import { codeVerifier2CodeChallenge } from "./oauth2"; const site = PRO_WEBSITE; console.debug(`remotelysave official website: ${site}`); @@ -25,31 +25,6 @@ export const DEFAULT_PRO_CONFIG: ProConfig = { email: "", }; -/** - * https://datatracker.ietf.org/doc/html/rfc7636 - * dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk - * => E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM - * @param x - * @returns BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) - */ -async function codeVerifier2CodeChallenge(x: string) { - if (x === undefined || x === "") { - return ""; - } - try { - return base64url.stringify( - new Uint8Array( - await crypto.subtle.digest("SHA-256", new TextEncoder().encode(x)) - ), - { - pad: false, - } - ); - } catch (e) { - return ""; - } -} - export const generateAuthUrlAndCodeVerifierChallenge = async ( hasCallback: boolean ) => { @@ -241,7 +216,6 @@ export const getAndSaveProEmail = async ( * @returns */ export const checkProRunnableAndFixInplace = async ( - featuresToCheck: PRO_FEATURE_TYPE[], config: RemotelySavePluginSettings, pluginVersion: string, saveUpdatedConfigFunc: () => Promise | undefined @@ -276,48 +250,59 @@ export const checkProRunnableAndFixInplace = async ( const errorMsgs = []; - // check for the features - if (featuresToCheck.contains("feature-smart_conflict")) { - if (config.conflictAction === "smart_conflict") { - if ( - config.pro.enabledProFeatures.filter( - (x) => x.featureName === "feature-smart_conflict" - ).length === 1 - ) { - // good to go - } else { - errorMsgs.push( - `You're trying to use "smart conflict" PRO feature but you haven't subscribe to it.` - ); - } - } else { + // check for smart_conflict + if (config.conflictAction === "smart_conflict") { + if ( + config.pro.enabledProFeatures.filter( + (x) => x.featureName === "feature-smart_conflict" + ).length === 1 + ) { // good to go + } else { + errorMsgs.push( + `You're trying to use "smart conflict" PRO feature but you haven't subscribe to it.` + ); } + } else { + // good to go } - if (featuresToCheck.contains("feature-google_drive")) { - console.debug( - `checking "feature-google_drive", serviceType=${config.serviceType}` - ); - console.debug( - `enabledProFeatures=${JSON.stringify(config.pro.enabledProFeatures)}` - ); - - if (config.serviceType === "googledrive") { - if ( - config.pro.enabledProFeatures.filter( - (x) => x.featureName === "feature-google_drive" - ).length === 1 - ) { - // good to go - } else { - errorMsgs.push( - `You're trying to use "sync with Google Drive" PRO feature but you haven't subscribe to it.` - ); - } - } else { + // check for google_drive + console.debug( + `checking "feature-google_drive", serviceType=${config.serviceType}` + ); + if (config.serviceType === "googledrive") { + if ( + config.pro.enabledProFeatures.filter( + (x) => x.featureName === "feature-google_drive" + ).length === 1 + ) { // good to go + } else { + errorMsgs.push( + `You're trying to use "sync with Google Drive" PRO feature but you haven't subscribe to it.` + ); } + } else { + // good to go + } + + // check for box + console.debug(`checking "feature-box", serviceType=${config.serviceType}`); + if (config.serviceType === "box") { + if ( + config.pro.enabledProFeatures.filter( + (x) => x.featureName === "feature-box" + ).length === 1 + ) { + // good to go + } else { + errorMsgs.push( + `You're trying to use "sync with Box" PRO feature but you haven't subscribe to it.` + ); + } + } else { + // good to go } if (errorMsgs.length !== 0) { diff --git a/pro/src/baseTypesPro.ts b/pro/src/baseTypesPro.ts index c072eeb..fef515c 100644 --- a/pro/src/baseTypesPro.ts +++ b/pro/src/baseTypesPro.ts @@ -1,4 +1,6 @@ -export const MERGABLE_SIZE = 1000 * 1000; // 1 MB +/////////////////////////////////////////////////////////// +// PRO +////////////////////////////////////////////////////////// export const COMMAND_CALLBACK_PRO = "remotely-save-cb-pro"; export const PRO_CLIENT_ID = process.env.DEFAULT_REMOTELYSAVE_CLIENT_ID; @@ -6,7 +8,8 @@ export const PRO_WEBSITE = process.env.DEFAULT_REMOTELYSAVE_WEBSITE; export type PRO_FEATURE_TYPE = | "feature-smart_conflict" - | "feature-google_drive"; + | "feature-google_drive" + | "feature-box"; export interface FeatureInfo { featureName: PRO_FEATURE_TYPE; @@ -24,6 +27,16 @@ export interface ProConfig { credentialsShouldBeDeletedAtTimeMs?: number; } +/////////////////////////////////////////////////////////// +// smart conflict +////////////////////////////////////////////////////////// + +export const MERGABLE_SIZE = 1000 * 1000; // 1 MB + +/////////////////////////////////////////////////////////// +// Google Drive +////////////////////////////////////////////////////////// + export interface GoogleDriveConfig { accessToken: string; accessTokenExpiresInMs: number; @@ -32,9 +45,28 @@ export interface GoogleDriveConfig { remoteBaseDir?: string; credentialsShouldBeDeletedAtTimeMs?: number; scope: "https://www.googleapis.com/auth/drive.file"; + kind: "googledrive"; } export const DEFAULT_GOOGLEDRIVE_CLIENT_ID = process.env.DEFAULT_GOOGLEDRIVE_CLIENT_ID; export const DEFAULT_GOOGLEDRIVE_CLIENT_SECRET = process.env.DEFAULT_GOOGLEDRIVE_CLIENT_SECRET; + +/////////////////////////////////////////////////////////// +// box +////////////////////////////////////////////////////////// + +export const COMMAND_CALLBACK_BOX = "remotely-save-cb-box"; +export const BOX_CLIENT_ID = process.env.DEFAULT_BOX_CLIENT_ID; +export const BOX_CLIENT_SECRET = process.env.DEFAULT_BOX_CLIENT_SECRET; + +export interface BoxConfig { + accessToken: string; + accessTokenExpiresInMs: number; + accessTokenExpiresAtTimeMs: number; + refreshToken: string; + remoteBaseDir?: string; + credentialsShouldBeDeletedAtTimeMs?: number; + kind: "box"; +} diff --git a/pro/src/fsBox.ts b/pro/src/fsBox.ts new file mode 100644 index 0000000..a033391 --- /dev/null +++ b/pro/src/fsBox.ts @@ -0,0 +1,927 @@ +import { BoxClient, BoxDeveloperTokenAuth } from "box-typescript-sdk-gen"; +import { + BoxOAuth, + OAuthConfig, +} from "box-typescript-sdk-gen/lib/box/oauth.generated"; +import * as mime from "mime-types"; +import { DEFAULT_CONTENT_TYPE, type Entity } from "../../src/baseTypes"; +import { FakeFs } from "../../src/fsAll"; +import { + BOX_CLIENT_ID, + BOX_CLIENT_SECRET, + type BoxConfig, + COMMAND_CALLBACK_BOX, +} from "./baseTypesPro"; + +import type { FileFull } from "box-typescript-sdk-gen/lib/schemas/fileFull.generated"; +import type { FileFullOrFolderMiniOrWebLink } from "box-typescript-sdk-gen/lib/schemas/fileFullOrFolderMiniOrWebLink.generated"; +import type { FolderFull } from "box-typescript-sdk-gen/lib/schemas/folderFull.generated"; +import type { Items } from "box-typescript-sdk-gen/lib/schemas/items.generated"; +import PQueue from "p-queue"; +import { + delay, + getFolderLevels, + getSha1, + splitFileSizeToChunkRanges, + unixTimeToStr, +} from "../../src/misc"; + +export const DEFAULT_BOX_CONFIG: BoxConfig = { + accessToken: "", + refreshToken: "", + accessTokenExpiresInMs: 0, + accessTokenExpiresAtTimeMs: 0, + credentialsShouldBeDeletedAtTimeMs: 0, // 60 days https://developer.box.com/guides/authentication/tokens/refresh/ + kind: "box", +}; + +export const generateAuthUrl = () => { + const config = new OAuthConfig({ + clientId: BOX_CLIENT_ID ?? "", + clientSecret: BOX_CLIENT_SECRET ?? "", + }); + const oauth = new BoxOAuth({ config: config }); + + // the URL to redirect the user to + const authorize_url = oauth.getAuthorizeUrl({ + redirectUri: `obsidian://${COMMAND_CALLBACK_BOX}`, + }); + // console.debug(authorize_url) + return authorize_url; +}; + +/** + * https://developer.box.com/guides/authentication/oauth2/without-sdk/ + */ +export const sendAuthReq = async (authCode: string, errorCallBack: any) => { + try { + const k = { + code: authCode, + grant_type: "authorization_code", + client_id: BOX_CLIENT_ID ?? "", + client_secret: BOX_CLIENT_SECRET ?? "", + // redirect_uri: `obsidian://${COMMAND_CALLBACK_BOX}`, + }; + // console.debug(k); + const resp1 = await fetch(`https://api.box.com/oauth2/token`, { + method: "POST", + body: new URLSearchParams(k), + }); + const resp2 = await resp1.json(); + return resp2; + } catch (e) { + console.error(e); + if (errorCallBack !== undefined) { + await errorCallBack(e); + } + } +}; + +/** + * https://developer.box.com/guides/authentication/tokens/refresh/ + */ +export const sendRefreshTokenReq = async (refreshToken: string) => { + console.debug(`refreshing token`); + const x = await fetch("https://api.box.com/oauth2/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: BOX_CLIENT_ID ?? "", + client_secret: BOX_CLIENT_SECRET ?? "", + grant_type: "refresh_token", + refresh_token: refreshToken, + }).toString(), + }); + + if (x.status === 200) { + const y = await x.json(); + console.debug(`new token obtained`); + return y; + } else { + throw Error(`cannot refresh an access token`); + } +}; + +export const setConfigBySuccessfullAuthInplace = async ( + config: BoxConfig, + authRes: any, + saveUpdatedConfigFunc: () => Promise | undefined +) => { + if (authRes.access_token === undefined || authRes.access_token === "") { + throw Error( + `you should not save the setting for ${JSON.stringify(authRes)}` + ); + } + + config.accessToken = authRes.access_token; + config.accessTokenExpiresAtTimeMs = + Date.now() + authRes.expires_in * 1000 - 5 * 60 * 1000; + config.accessTokenExpiresInMs = authRes.expires_in * 1000; + config.refreshToken = authRes.refresh_token || config.refreshToken; + + // manually set it expired after 60 days; + config.credentialsShouldBeDeletedAtTimeMs = + Date.now() + 1000 * 60 * 60 * 24 * 59; + + await saveUpdatedConfigFunc?.(); + + console.info("finish updating local info of Box token"); +}; + +interface CreateUploadSessionRawResponse { + id: string; + type: "upload_session"; + num_parts_processed: number; + part_size: number; + session_endpoints: { + abort: string; + commit: string; + list_parts: string; + log_event: string; + status: string; + upload_part: string; + }; + session_expires_at: string; + total_parts: number; +} + +interface UploadChunkRawResponse { + part: { + offset: number; + part_id: string; + sha1: string; + size: number; + }; +} + +interface BoxEntity extends Entity { + id: string; + parentID: string | undefined; + parentIDPath: string | undefined; + isFolder: boolean; + hashSha1: string | undefined; +} + +const fromBoxItemToEntity = ( + boxItem: FileFullOrFolderMiniOrWebLink | FolderFull, + parentID: string, + parentFolderPath: string | undefined /* for bfs */ +): BoxEntity => { + if (parentID === undefined || parentID === "" || parentID === "0") { + throw Error(`parentID=${parentID} should not be in fromBoxItemToEntity`); + } + + let keyRaw = boxItem.name!; + + if ( + parentFolderPath !== undefined && + parentFolderPath !== "" && + parentFolderPath !== "/" + ) { + if (!parentFolderPath.endsWith("/")) { + throw Error( + `parentFolderPath=${parentFolderPath} should not be in fromFileToBoxEntity` + ); + } + keyRaw = `${parentFolderPath}${boxItem.name!}`; + } + + if (boxItem.type === "folder") { + keyRaw = `${keyRaw}/`; + const mtime = + (boxItem as FolderFull).contentModifiedAt?.value.valueOf() ?? + (boxItem as FolderFull).modifiedAt?.value.valueOf() ?? + Date.now(); + return { + key: keyRaw, + keyRaw: keyRaw, + mtimeCli: mtime, + mtimeSvr: mtime, + id: boxItem.id, + parentID: parentID, + isFolder: true, + size: 0, + sizeRaw: 0, + hash: undefined, + hashSha1: undefined, + parentIDPath: parentFolderPath, + }; + } else if (boxItem.type === "file") { + const mtime = + boxItem.contentModifiedAt?.value.valueOf() ?? + boxItem.modifiedAt?.value.valueOf() ?? + Date.now(); + return { + key: keyRaw, + keyRaw: keyRaw, + mtimeCli: mtime, + mtimeSvr: mtime, + id: boxItem.id, + parentID: parentID, + isFolder: false, + size: boxItem.size!, + sizeRaw: boxItem.size!, + hash: boxItem.sha1, + hashSha1: boxItem.sha1, + parentIDPath: parentFolderPath, + }; + } else { + throw Error(`we do not support web link Box item`); + } +}; + +const fromRawResponseToEntity = ( + boxItem: any, + parentID: string, + parentFolderPath: string | undefined /* for bfs */ +): BoxEntity => { + if (parentID === undefined || parentID === "" || parentID === "0") { + throw Error( + `parentID=${parentID} should not be in fromRawResponseToEntity` + ); + } + + let keyRaw = boxItem.name!; + + if ( + parentFolderPath !== undefined && + parentFolderPath !== "" && + parentFolderPath !== "/" + ) { + if (!parentFolderPath.endsWith("/")) { + throw Error( + `parentFolderPath=${parentFolderPath} should not be in fromFileToBoxEntity` + ); + } + keyRaw = `${parentFolderPath}${boxItem.name!}`; + } + + if (boxItem.type === "folder") { + keyRaw = `${keyRaw}/`; + } else if (boxItem.type === "file") { + // pass + } else { + throw Error(`we do not support web link Box item`); + } + + const mtimeStr = boxItem.content_modified_at ?? boxItem.modified_at; + let mtime = Date.now(); + if (mtimeStr !== undefined) { + mtime = new Date(mtimeStr).valueOf(); + } + + return { + key: keyRaw, + keyRaw: keyRaw, + mtimeCli: mtime, + mtimeSvr: mtime, + id: boxItem.id, + parentID: parentID, + isFolder: false, + size: boxItem.size ?? 0, + sizeRaw: boxItem.size ?? 0, + hash: boxItem.sha1 ?? undefined, + hashSha1: boxItem.sha1 ?? undefined, + parentIDPath: parentFolderPath, + }; +}; + +export class FakeFsBox extends FakeFs { + kind: string; + boxConfig: BoxConfig; + remoteBaseDir: string; + vaultFolderExists: boolean; + saveUpdatedConfigFunc: () => Promise; + + keyToBoxEntity: Record; + + baseDirID: string; + + constructor( + boxConfig: BoxConfig, + vaultName: string, + saveUpdatedConfigFunc: () => Promise + ) { + super(); + this.kind = "box"; + this.boxConfig = boxConfig; + this.remoteBaseDir = this.boxConfig.remoteBaseDir || vaultName || ""; + this.vaultFolderExists = false; + this.saveUpdatedConfigFunc = saveUpdatedConfigFunc; + this.keyToBoxEntity = {}; + this.baseDirID = ""; + } + + async _init() { + const access = await this._getAccessToken(); + + if (this.vaultFolderExists) { + // pass + } else { + const auth = new BoxDeveloperTokenAuth({ token: access }); + const client = new BoxClient({ auth }); + + // find + let itemsInRoot: Items | undefined = undefined; + + let offset = 0; + const limitPerPage = 1000; // max 1000 + + while (!this.vaultFolderExists) { + itemsInRoot = await client.folders.getFolderItems("0", { + queryParams: { + fields: [ + "id", + "type", + "name", + "sha1", + "size", + "created_at", + "modified_at", + "expires_at", + "parent", + "content_created_at", + "content_modified_at", + "etag", + ], + offset: offset, + limit: limitPerPage, + }, + }); + // console.debug(`this.remoteBaseDir=${this.remoteBaseDir}`); + // console.debug(`itemsInRoot:`); + // console.debug(itemsInRoot); + if ( + (itemsInRoot.entries?.filter((x) => x.name === this.remoteBaseDir) + .length ?? 0) > 0 + ) { + // we find it! + const f = itemsInRoot.entries?.filter( + (x) => x.name === this.remoteBaseDir + )[0]!; + this.baseDirID = f.id; + this.vaultFolderExists = true; + break; + } + + if ((itemsInRoot.offset ?? 0) >= (itemsInRoot.totalCount ?? 0)) { + break; + } + + offset += limitPerPage; + } + + if (!this.vaultFolderExists) { + // create + const f = await client.folders.createFolder({ + name: this.remoteBaseDir, + parent: { id: "0" }, + }); + this.baseDirID = f.id; + this.vaultFolderExists = true; + } + } + } + + async _getAccessToken() { + if (this.boxConfig.refreshToken === "") { + throw Error("The user has not manually auth yet."); + } + + const ts = Date.now(); + const comp = this.boxConfig.accessTokenExpiresAtTimeMs > ts; + // console.debug(`this.boxConfig.accessTokenExpiresAtTimeMs=${this.boxConfig.accessTokenExpiresAtTimeMs},ts=${ts},comp=${comp}`) + if (comp) { + return this.boxConfig.accessToken; + } + + // refresh + const k = await sendRefreshTokenReq(this.boxConfig.refreshToken); + this.boxConfig.accessToken = k.access_token; + this.boxConfig.accessTokenExpiresInMs = k.expires_in * 1000; + this.boxConfig.accessTokenExpiresAtTimeMs = + ts + k.expires_in * 1000 - 60 * 2 * 1000; + await this.saveUpdatedConfigFunc(); + console.info("Box accessToken updated"); + return this.boxConfig.accessToken; + + // const access = "tUf643YKLuGhUmXRBKPAK0hz9ZKv85kS"; + // this.boxConfig.accessToken = access; + // return access; + } + + async walk(): Promise { + await this._init(); + + const allFiles: BoxEntity[] = []; + + // bfs + const queue = new PQueue({ + concurrency: 5, // TODO: make it configurable? + autoStart: true, + }); + queue.on("error", (error) => { + queue.pause(); + queue.clear(); + throw error; + }); + + let parents = [ + { + id: this.baseDirID, // special init, from already created root folder ID + folderPath: "", + }, + ]; + + while (parents.length !== 0) { + // console.debug('enter while loop 1 of parents array'); + const children: typeof parents = []; + for (const { id, folderPath } of parents) { + queue.add(async () => { + const filesUnderFolder = await this._walkFolder(id, folderPath); + for (const f of filesUnderFolder) { + allFiles.push(f); + if (f.isFolder) { + // keyRaw itself already has a tailing slash, no more slash here + // keyRaw itself also already has full path + const child = { + id: f.id, + folderPath: f.keyRaw, + }; + // console.debug( + // `looping result of _walkFolder(${id},${folderPath}), adding child=${JSON.stringify( + // child + // )}` + // ); + children.push(child); + } + } + }); + } + await queue.onIdle(); + parents = children; + } + + // console.debug(`in the end of walk:`); + // console.debug(allFiles); + // console.debug(this.keyToBoxEntity); + return allFiles; + } + + async _walkFolder( + parentID: string, + parentFolderPath: string + ): Promise { + // console.debug( + // `input of single level: parentID=${parentID}, parentFolderPath=${parentFolderPath}` + // ); + const filesOneLevel: BoxEntity[] = []; + const access = await this._getAccessToken(); + const auth = new BoxDeveloperTokenAuth({ token: access }); + const client = new BoxClient({ auth }); + + if (parentID === undefined || parentID === "" || parentID === "root") { + // we should never start from root + // because we encapsulate the vault inside a folder + throw Error(`something goes wrong walking folder`); + } + + let items: Items | undefined = undefined; + + let offset = 0; + const limitPerPage = 1000; // max 1000 + do { + // console.debug(`entering paging of parentID=${parentID}, offset=${offset}`); + items = await client.folders.getFolderItems(parentID, { + queryParams: { + fields: [ + "id", + "type", + "name", + "sha1", + "size", + "created_at", + "modified_at", + "expires_at", + "parent", + "content_created_at", + "content_modified_at", + "etag", + ], + offset: offset, + limit: limitPerPage, + }, + }); + // console.debug(`items of parentID=${parentID},offset=${offset}:`); + // console.debug(items); + + for (const item of items.entries ?? []) { + const entity = fromBoxItemToEntity(item, parentID, parentFolderPath); + this.keyToBoxEntity[entity.keyRaw] = entity; // build cache + filesOneLevel.push(entity); + } + + offset += limitPerPage; + // console.debug(`end of current loop parentID=${parentID}, and offset=${offset}`); + } while (offset < (items?.totalCount ?? 0)); + + return filesOneLevel; + } + + async walkPartial(): Promise { + await this._init(); + const filesInLevel = await this._walkFolder(this.baseDirID, ""); + return filesInLevel; + } + + async stat(key: string): Promise { + await this._init(); + + // TODO: we already have a cache, should we call again? + const cachedEntity = this.keyToBoxEntity[key]; + const fileID = cachedEntity?.id; + if (cachedEntity === undefined || fileID === undefined) { + throw Error(`no fileID found for key=${key}`); + } + + const access = await this._getAccessToken(); + const auth = new BoxDeveloperTokenAuth({ token: access }); + const client = new BoxClient({ auth }); + + let f: FileFull | FolderFull | undefined; + if (cachedEntity.isFolder) { + f = await client.folders.getFolderById(fileID); + } else { + f = await client.files.getFileById(fileID); + } + const entity = fromBoxItemToEntity( + f, + cachedEntity.parentID!, + cachedEntity.parentIDPath! + ); + // insert back to cache?? to update it?? + this.keyToBoxEntity[key] = entity; + return entity; + } + + async mkdir( + key: string, + mtime?: number | undefined, + ctime?: number | undefined + ): Promise { + if (!key.endsWith("/")) { + throw Error(`you should not mkdir on key=${key}`); + } + + await this._init(); + + const cachedEntity = this.keyToBoxEntity[key]; + const fileID = cachedEntity?.id; + if (cachedEntity !== undefined && fileID !== undefined) { + return cachedEntity; + } + + // xxx/ => ["xxx"] + // xxx/yyy/zzz/ => ["xxx", "xxx/yyy", "xxx/yyy/zzz"] + const folderLevels = getFolderLevels(key); + let parentFolderPath: string | undefined = undefined; + let parentID: string | undefined = undefined; + if (folderLevels.length === 0) { + throw Error(`cannot getFolderLevels of ${key}`); + } else if (folderLevels.length === 1) { + parentID = this.baseDirID; + parentFolderPath = ""; // ignore base dir + } else { + // length > 1 + parentFolderPath = `${folderLevels[folderLevels.length - 2]}/`; + if (!(parentFolderPath in this.keyToBoxEntity)) { + throw Error( + `parent of ${key}: ${parentFolderPath} is not created before??` + ); + } + parentID = this.keyToBoxEntity[parentFolderPath].id; + } + + // xxx/yyy/zzz/ => ["xxx", "xxx/yyy", "xxx/yyy/zzz"] => "xxx/yyy/zzz" => "zzz" + let folderItselfWithoutSlash = folderLevels[folderLevels.length - 1]; + folderItselfWithoutSlash = folderItselfWithoutSlash.split("/").pop()!; + + const access = await this._getAccessToken(); + const auth = new BoxDeveloperTokenAuth({ token: access }); + const client = new BoxClient({ auth }); + const f = await client.folders.createFolder({ + name: folderItselfWithoutSlash, + parent: { id: parentID }, + }); + + const entity = fromBoxItemToEntity(f, parentID, parentFolderPath); + // insert into cache + this.keyToBoxEntity[key] = entity; + return entity; + } + + async writeFile( + key: string, + content: ArrayBuffer, + mtime: number, + ctime: number + ): Promise { + if (key.endsWith("/")) { + throw Error(`should not call writeFile on ${key}`); + } + + await this._init(); + + const prevCachedEntity: BoxEntity | undefined = this.keyToBoxEntity[key]; + const prevFileID: string | undefined = prevCachedEntity?.id; + + const contentType = + mime.contentType(mime.lookup(key) || DEFAULT_CONTENT_TYPE) || + DEFAULT_CONTENT_TYPE; + + let parentID: string | undefined = undefined; + let parentFolderPath: string | undefined = undefined; + + // "xxx" => [] + // "xxx/yyy/zzz.md" => ["xxx", "xxx/yyy"] + const folderLevels = getFolderLevels(key); + if (folderLevels.length === 0) { + // root + parentID = this.baseDirID; + parentFolderPath = ""; + } else { + parentFolderPath = `${folderLevels[folderLevels.length - 1]}/`; + if (!(parentFolderPath in this.keyToBoxEntity)) { + throw Error( + `parent of ${key}: ${parentFolderPath} is not created before??` + ); + } + parentID = this.keyToBoxEntity[parentFolderPath].id; + } + + const fileItself = key.split("/").pop()!; + + const BIG_FILE_THRESHOLD = 20000000; // box api hard coded... + if (content.byteLength <= BIG_FILE_THRESHOLD) { + const formData = new FormData(); + const attributes = { + name: fileItself, + parent: { id: parentID }, + content_created_at: unixTimeToStr(ctime), + content_modified_at: unixTimeToStr(mtime), + }; + formData.append("attributes", JSON.stringify(attributes)); + formData.append("file", new Blob([content], { type: contentType })); + + let url = ""; + if (prevFileID === undefined) { + // create new file + // https://developer.box.com/reference/post-files-content/ + url = `https://upload.box.com/api/2.0/files/content`; + } else { + // update new file + // https://developer.box.com/reference/post-files-id-content/ + url = `https://upload.box.com/api/2.0/files/${prevFileID}/content`; + } + + const res = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${await this._getAccessToken()}`, + }, + body: formData, + }); + + if (res.status !== 200 && res.status !== 201) { + throw Error( + `create file ${key} failed! attributes=${JSON.stringify(attributes)}` + ); + } + + const res2 = await res.json(); + if (res2.entries === undefined) { + throw Error(`upload small file ${key} failed!`); + } + const entity = fromRawResponseToEntity( + res2.entries[0], + parentID, + parentFolderPath + ); + this.keyToBoxEntity[key] = entity; + // console.debug(`entity after upload=${JSON.stringify(entity, null, 2)}`); + return entity; + } else { + // create session + + let url = ""; + if (prevFileID === undefined) { + // https://developer.box.com/reference/post-files-upload-sessions/ + url = "https://upload.box.com/api/2.0/files/upload_sessions"; + } else { + // https://developer.box.com/reference/post-files-id-upload-sessions/ + url = `https://upload.box.com/api/2.0/files/${prevFileID}/upload_sessions`; + } + + const sessionRes1 = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${await this._getAccessToken()}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + file_name: fileItself, + file_size: content.byteLength, + folder_id: parentID, + }), + }); + if (sessionRes1.status !== 200 && sessionRes1.status !== 201) { + throw Error( + `Create upload session for ${key} failed! Response header=${JSON.stringify( + sessionRes1.headers + )}` + ); + } + const sessionRes2: CreateUploadSessionRawResponse = + await sessionRes1.json(); + // console.debug(sessionRes2); + + // upload by chunks + const sizePerChunk = sessionRes2.part_size; + const chunkRanges = splitFileSizeToChunkRanges( + content.byteLength, + sizePerChunk + ); + // TODO: parallel + const partsResult: UploadChunkRawResponse[] = []; + for (const { start, end } of chunkRanges) { + const subContent = content.slice(start, end + 1); + const sha1 = await getSha1(subContent, "base64"); + const res = await fetch(sessionRes2.session_endpoints.upload_part, { + method: "PUT", + headers: { + Authorization: `Bearer ${await this._getAccessToken()}`, + // "Content-Length": `${end - start + 1}`, // the number of bytes in the current chunk + "Content-Range": `bytes ${start}-${end}/${content.byteLength}`, + "Content-Type": "application/octet-stream", + digest: `sha=${sha1}`, + }, + body: subContent, + }); + if (res.status !== 200 && res.status !== 201) { + throw Error( + `Upload chunk for ${key}, ${start}-${end} failed! Response header=${JSON.stringify( + res.headers + )}` + ); + } + + partsResult.push((await res.json()) as UploadChunkRawResponse); + } + // commit? + const sha1 = await getSha1(content, "base64"); + let status = 202; + let tries = 0; + do { + // console.debug(`begin commit key=${key} for tries=${tries}`) + const commitRes1 = await fetch(sessionRes2.session_endpoints.commit, { + method: "POST", + headers: { + Authorization: `Bearer ${await this._getAccessToken()}`, + + "Content-Type": "application/json", + digest: `sha=${sha1}`, + }, + body: JSON.stringify({ + parts: partsResult.map((p) => p.part), + attributes: { + content_modified_at: unixTimeToStr(mtime, false), + content_created_at: unixTimeToStr(ctime, false), + }, + }), + }); + status = commitRes1.status; + // console.debug(`status===${status} for tries=${tries},key=${key}`) + if (status === 200 || status === 201) { + const commitRes2 = await commitRes1.json(); + if (commitRes2.entries === undefined) { + throw Error(`Upload big file ${key} failed!`); + } + const entity = fromRawResponseToEntity( + commitRes2.entries[0], + parentID, + parentFolderPath + ); + this.keyToBoxEntity[key] = entity; + // console.debug( + // `entity after upload=${JSON.stringify(entity, null, 2)}` + // ); + return entity; + } else if (status === 202) { + await delay(500); + tries += 1; + } else { + throw Error( + `Commit all chunks for ${key} failed! Response header=${JSON.stringify( + commitRes1.headers + )}` + ); + } + // console.debug(`end commit key=${key}, currently status===${status}, tries===${tries} for next loop`) + } while (status === 202 && tries < 4); + throw Error(`Commit all chunks for ${key} failed! No idea what happened`); + } + } + + async readFile(key: string): Promise { + await this._init(); + + const cachedEntity = this.keyToBoxEntity[key]; + const fileID = cachedEntity?.id; + if (cachedEntity === undefined || fileID === undefined) { + throw Error(`no fileID found for key=${key}`); + } + + const res1 = await fetch( + `https://api.box.com/2.0/files/${fileID}/content`, + { + method: "GET", + headers: { + Authorization: `Bearer ${await this._getAccessToken()}`, + }, + } + ); + if (res1.status !== 200) { + throw Error( + `Cannot download file ${key} with id ${fileID}. Response header=${JSON.stringify( + res1.headers + )}` + ); + } + const res2 = await res1.arrayBuffer(); + return res2; + } + + async rename(key1: string, key2: string): Promise { + throw new Error("Method not implemented."); + } + + async rm(key: string): Promise { + await this._init(); + + const cachedEntity = this.keyToBoxEntity[key]; + const fileID = cachedEntity?.id; + if (cachedEntity === undefined || fileID === undefined) { + throw Error(`no fileID found for key=${key}`); + } + + const access = await this._getAccessToken(); + const auth = new BoxDeveloperTokenAuth({ token: access }); + const client = new BoxClient({ auth }); + + if (cachedEntity.isFolder) { + await client.folders.deleteFolderById(fileID, { + queryParams: { recursive: true }, + }); + } else { + await client.files.deleteFileById(fileID); + } + } + + async checkConnect(callbackFunc?: any): Promise { + // if we can init, we can connect + try { + await this._init(); + return true; + } catch (err) { + console.debug(err); + callbackFunc?.(err); + return false; + } + } + async getUserDisplayName(): Promise { + throw new Error("Method not implemented."); + } + + /** + * https://developer.box.com/guides/authentication/tokens/revoke/ + */ + async revokeAuth(): Promise { + await fetch(`https://api.box.com/oauth2/revoke`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + client_id: BOX_CLIENT_ID ?? "", + client_secret: BOX_CLIENT_SECRET ?? "", + token: this.boxConfig.refreshToken, + }).toString(), + }); + } + + allowEmptyFile(): boolean { + return true; + } +} diff --git a/pro/src/fsGoogleDrive.ts b/pro/src/fsGoogleDrive.ts index 5d874b6..392c0f1 100644 --- a/pro/src/fsGoogleDrive.ts +++ b/pro/src/fsGoogleDrive.ts @@ -26,6 +26,7 @@ export const DEFAULT_GOOGLEDRIVE_CONFIG: GoogleDriveConfig = { accessTokenExpiresAtTimeMs: 0, credentialsShouldBeDeletedAtTimeMs: 0, scope: "https://www.googleapis.com/auth/drive.file", + kind: "googledrive", }; const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; diff --git a/pro/src/langs/en.json b/pro/src/langs/en.json index 9a2e95c..e994537 100644 --- a/pro/src/langs/en.json +++ b/pro/src/langs/en.json @@ -7,6 +7,11 @@ "protocol_pro_connect_fail": "Something went wrong from response from Remotely Save official website. Maybe the network connection is not good. Maybe you rejected the auth?", "protocol_pro_connect_succ_revoke": "You've connected as user {{email}}. If you want to disconnect, click this button.", + "protocol_box_connecting": "Connectting", + "protocol_box_connect_manualinput_succ": "You've connected", + "protocol_box_connect_fail": "Something went wrong from response from Box official website. Maybe the network connection is not good. Maybe you rejected the auth?", + "protocol_box_connect_succ_revoke": "You've connected. If you want to disconnect, click this button.", + "modal_googledriveauth_tutorial": "

Please firstly go to the address, then go on the auth flow. In the end, you will see a code, please paste that code here and submit.

", "modal_googledriveauth_copybutton": "Click to copy the auth url", "modal_googledriveauth_copynotice": "The auth url is copied to the clipboard!", @@ -23,6 +28,22 @@ "modal_googledriverevokeauth_clean_notice": "Cleaned!", "modal_googledriverevokeauth_clean_fail": "Something goes wrong while revoking.", + "modal_boxauth_tutorial": "

Please firstly go to the address, then go on the auth flow. In the end, you will be redirected to here.

", + "modal_boxauth_copybutton": "Click to copy the auth url", + "modal_boxauth_copynotice": "The auth url is copied to the clipboard!", + "modal_box_maualinput": "The Code from the website", + "modal_box_maualinput_desc": "Please input the code here from the end of auth flow, and press confirm.", + "modal_box_maualinput_notice": "We are trying to connect to Box and update the credentials...", + "modal_box_maualinput_succ_notice": "Great! The credentials are updated!", + "modal_box_maualinput_fail_notice": "Oops! Failed to update the credentials. Please try again later.", + "modal_boxrevokeauth_step1": "Step 1: Go to the following address, you can remove the connection there.", + "modal_boxrevokeauth_step2": "Step 2: Click the button below, to clean the locally-saved login credentials.", + "modal_boxrevokeauth_clean": "Clean Locally-Saved Login Credentials", + "modal_boxrevokeauth_clean_desc": "You need to click the button.", + "modal_boxrevokeauth_clean_button": "Clean", + "modal_boxrevokeauth_clean_notice": "Cleaned!", + "modal_boxrevokeauth_clean_fail": "Something goes wrong while revoking.", + "modal_prorevokeauth": "Revoke auth by clicking here and follow the steps.", "modal_prorevokeauth_clean": "Clean", "modal_prorevokeauth_clean_desc": "Clean local auth record", @@ -54,7 +75,26 @@ "settings_googledrive_connect_succ": "Great! We can connect to Google Drive!", "settings_googledrive_connect_fail": "We cannot connect to Google Drive.", + "settings_box": "Box (PRO) (beta)", + "settings_chooseservice_box": "Box (PRO) (beta)", + "settings_box_disclaimer1": "Disclaimer: This app is NOT an official Box product. The app just uses Box's public api.", + "settings_box_disclaimer2": "Disclaimer: The information is stored locally. Other malicious/harmful/faulty plugins could read the info. If you see any unintentional access to your Box, please immediately disconnect this app on https://app.box.com/account/security .", + "settings_box_pro_desc": "

!!It's a PRO feature of Remotely Save! You need a Remotely Save online account for this feature!!(scroll down for more info about PRO account.)

", + "settings_box_notshowuphint": "Box Settings Not Available", + "settings_box_notshowuphint_desc": "Box settings are not available, because you haven't subscribed to the PRO feature in your Remotely Save account.", + "settings_box_notshowuphint_view_pro": "View PRO Settings", + "settings_box_folder": "We will create and sync inside the folder {{remoteBaseDir}} on your Box. DO NOT create this folder by yourself manually.", + "settings_box_revoke": "Revoke Auth", + "settings_box_revoke_desc": "You've connected. If you want to disconnect, click this button.", + "settings_box_revoke_button": "Revoke Auth", + "settings_box_auth": "Auth", + "settings_box_auth_desc": "Auth.", + "settings_box_auth_button": "Auth", + "settings_box_connect_succ": "Great! We can connect to Box!", + "settings_box_connect_fail": "We cannot connect to Box.", + "settings_export_googledrive_button": "Export Google Drive Part", + "settings_export_box_button": "Export Box Part", "settings_pro": "Account (for PRO features)", "settings_pro_tutorial": "

Using basic features of Remotely Save is FREE and do NOT need an account.

However, you will need an online account and PAY for the PRO features such as smart conflict.

Firstly please click the button to sign up and sign in to the website: https://remotelysave.com. Notice: It's different from, and NOT affiliated with Obsidian account.

Secondly please \"connect\" your local device to your online account.", diff --git a/pro/src/langs/zh_cn.json b/pro/src/langs/zh_cn.json index 5f407cb..c1c8918 100644 --- a/pro/src/langs/zh_cn.json +++ b/pro/src/langs/zh_cn.json @@ -7,6 +7,11 @@ "protocol_pro_connect_fail": "Remotely Save 官网返回错误。可能是网络连接不稳定。也可能是您拒绝了授权?", "protocol_pro_connect_succ_revoke": "您已连接上账号 {{email}}。如果要取消连接,请点击此按钮。", + "protocol_box_connecting": "正在连接", + "protocol_box_connect_manualinput_succ": "连接成功", + "protocol_box_connect_fail": "Box 官网返回错误。可能是网络连接不稳定。也可能是您拒绝了授权?", + "protocol_box_connect_succ_revoke": "您已连接上账号。如果要取消连接,请点击此按钮。", + "modal_googledriveauth_tutorial": "

请访问此网址,然后会进入授权流程。最后,您会看到一个码,请复制粘贴到这里然后提交。

", "modal_googledriveauth_copybutton": "点击以复制网址", "modal_googledriveauth_copynotice": "网址已复制!", @@ -23,6 +28,22 @@ "modal_googledriverevokeauth_clean_notice": "已清理!", "modal_googledriverevokeauth_clean_fail": "清理授权时候发生了错误。", + "modal_boxauth_tutorial": "

请访问此网址,然后会进入授权流程。最后,您会被重定向回来。

", + "modal_boxauth_copybutton": "点击以复制网址", + "modal_boxauth_copynotice": "网址已复制!", + "modal_box_maualinput": "网站上的码", + "modal_box_maualinput_desc": "请粘贴授权流程最后的那个码,然后点击确认。", + "modal_box_maualinput_notice": "正在尝试连接 Box 并更新授权信息......", + "modal_box_maualinput_succ_notice": "很好!授权信息已更新!", + "modal_box_maualinput_fail_notice": "更新授权信息失败。请稍后重试。", + "modal_boxrevokeauth_step1": "第 1 步:访问以下网址,可以删除连接。", + "modal_boxrevokeauth_step2": "第 2 步:点击以下按钮,从而清理本地的登录信息。", + "modal_boxrevokeauth_clean": "清理本地登录信息", + "modal_boxrevokeauth_clean_desc": "您需要点击此按钮。", + "modal_boxrevokeauth_clean_button": "清理", + "modal_boxrevokeauth_clean_notice": "已清理!", + "modal_boxrevokeauth_clean_fail": "清理授权时候发生了错误。", + "modal_prorevokeauth": "点击这里和按照步骤取消授权。", "modal_prorevokeauth_clean": "清理", "modal_prorevokeauth_clean_desc": "清理本地授权记录", @@ -54,7 +75,26 @@ "settings_googledrive_connect_succ": "很好!我们可连接上 Google Drive!", "settings_googledrive_connect_fail": "我们未能连接上 Google Drive。", + "settings_box": "Box (PRO) (beta)", + "settings_chooseservice_box": "Box (PRO) (beta)", + "settings_box_disclaimer1": "声明:本插件不是 Box 的官方产品。只是用到了它的公开 API。", + "settings_box_disclaimer2": "声明:您所输入的信息存储于本地。其它有害的或者出错的插件,是有可能读取到这些信息的。如果您发现任何不符合预期的 Box 访问,请立刻在以下网站操作断开连接: https://app.box.com/account/security 。", + "settings_box_pro_desc": "

!!这是 PRO(付费)功能! 您需要在线账号来使用此功能!!向下滑可以看到 PRO 账号的更多信息。)

", + "settings_box_notshowuphint": "Box 设置不可用", + "settings_box_notshowuphint_desc": "Box 设置不可用,因为您没有在 Remotely Save 账号里开启这个 PRO 功能。", + "settings_box_notshowuphint_view_pro": "查看 PRO 相关设置", + "settings_box_folder": "我们会在 Box 创建此文件夹并同步内容进去: {{remoteBaseDir}} 。请不要手动在网站上创建。", + "settings_box_revoke": "撤回鉴权", + "settings_box_revoke_desc": "您现在已连接。如果想取消连接,请点击此按钮。", + "settings_box_revoke_button": "撤回鉴权", + "settings_box_auth": "鉴权", + "settings_box_auth_desc": "鉴权.", + "settings_box_auth_button": "鉴权", + "settings_box_connect_succ": "很好!我们可连接上 Box!", + "settings_box_connect_fail": "我们未能连接上 Box。", + "settings_export_googledrive_button": "导出 Google Drive 部分", + "settings_export_box_button": "导出 Box 部分", "settings_pro": "账号(PRO 付费功能)", "settings_pro_tutorial": "

使用 Remotely Save 的基本功能是免费的,而且需要注册对应账号。

但是,您需要注册账号和对PRO功能付费使用,如智能处理冲突功能。

第一步:点击按钮从而注册和登录网站:https://remotelysave.com。注意:这和 Obsidian 官方账号无关,是不同的账号。

第二部:点击“连接”按钮,从而连接本设备和在线账号。", diff --git a/pro/src/langs/zh_tw.json b/pro/src/langs/zh_tw.json index 7fa12f7..bf442f5 100644 --- a/pro/src/langs/zh_tw.json +++ b/pro/src/langs/zh_tw.json @@ -7,6 +7,11 @@ "protocol_pro_connect_fail": "Remotely Save 官網返回錯誤。可能是網路連線不穩定。也可能是您拒絕了授權?", "protocol_pro_connect_succ_revoke": "您已連線上賬號 {{email}}。如果要取消連線,請點選此按鈕。", + "protocol_box_connecting": "正在連線", + "protocol_box_connect_manualinput_succ": "連線成功", + "protocol_box_connect_fail": "Box 官網返回錯誤。可能是網路連線不穩定。也可能是您拒絕了授權?", + "protocol_box_connect_succ_revoke": "您已連線上賬號。如果要取消連線,請點選此按鈕。", + "modal_googledriveauth_tutorial": "

請訪問此網址,然後會進入授權流程。最後,您會看到一個碼,請複製貼上到這裡然後提交。

", "modal_googledriveauth_copybutton": "點選以複製網址", "modal_googledriveauth_copynotice": "網址已複製!", @@ -54,7 +59,42 @@ "settings_googledrive_connect_succ": "很好!我們可連線上 Google Drive!", "settings_googledrive_connect_fail": "我們未能連線上 Google Drive。", + "modal_boxauth_tutorial": "

請訪問此網址,然後會進入授權流程。最後,您會被重定向回來。

", + "modal_boxauth_copybutton": "點選以複製網址", + "modal_boxauth_copynotice": "網址已複製!", + "modal_box_maualinput": "網站上的碼", + "modal_box_maualinput_desc": "請貼上授權流程最後的那個碼,然後點選確認。", + "modal_box_maualinput_notice": "正在嘗試連線 Box 並更新授權資訊......", + "modal_box_maualinput_succ_notice": "很好!授權資訊已更新!", + "modal_box_maualinput_fail_notice": "更新授權資訊失敗。請稍後重試。", + "modal_boxrevokeauth_step1": "第 1 步:訪問以下網址,可以刪除連線。", + "modal_boxrevokeauth_step2": "第 2 步:點選以下按鈕,從而清理本地的登入資訊。", + "modal_boxrevokeauth_clean": "清理本地登入資訊", + "modal_boxrevokeauth_clean_desc": "您需要點選此按鈕。", + "modal_boxrevokeauth_clean_button": "清理", + "modal_boxrevokeauth_clean_notice": "已清理!", + "modal_boxrevokeauth_clean_fail": "清理授權時候發生了錯誤。", + + "settings_box": "Box (PRO) (beta)", + "settings_chooseservice_box": "Box (PRO) (beta)", + "settings_box_disclaimer1": "宣告:本外掛不是 Box 的官方產品。只是用到了它的公開 API。", + "settings_box_disclaimer2": "宣告:您所輸入的資訊儲存於本地。其它有害的或者出錯的外掛,是有可能讀取到這些資訊的。如果您發現任何不符合預期的 Box 訪問,請立刻在以下網站操作斷開連線: https://app.box.com/account/security 。", + "settings_box_pro_desc": "

!!這是 PRO(付費)功能! 您需要線上賬號來使用此功能!!向下滑可以看到 PRO 賬號的更多資訊。)

", + "settings_box_notshowuphint": "Box 設定不可用", + "settings_box_notshowuphint_desc": "Box 設定不可用,因為您沒有在 Remotely Save 賬號裡開啟這個 PRO 功能。", + "settings_box_notshowuphint_view_pro": "檢視 PRO 相關設定", + "settings_box_folder": "我們會在 Box 建立此資料夾並同步內容進去: {{remoteBaseDir}} 。請不要手動在網站上建立。", + "settings_box_revoke": "撤回鑑權", + "settings_box_revoke_desc": "您現在已連線。如果想取消連線,請點選此按鈕。", + "settings_box_revoke_button": "撤回鑑權", + "settings_box_auth": "鑑權", + "settings_box_auth_desc": "鑑權.", + "settings_box_auth_button": "鑑權", + "settings_box_connect_succ": "很好!我們可連線上 Box!", + "settings_box_connect_fail": "我們未能連線上 Box。", + "settings_export_googledrive_button": "匯出 Google Drive 部分", + "settings_export_box_button": "匯出 Box 部分", "settings_pro": "賬號(PRO 付費功能)", "settings_pro_tutorial": "

使用 Remotely Save 的基本功能是免費的,而且需要註冊對應賬號。

但是,您需要註冊賬號和對PRO功能付費使用,如智慧處理衝突功能。

第一步:點選按鈕從而註冊和登入網站:https://remotelysave.com。注意:這和 Obsidian 官方賬號無關,是不同的賬號。

第二部:點選“連線”按鈕,從而連線本裝置和線上賬號。", diff --git a/pro/src/oauth2.ts b/pro/src/oauth2.ts new file mode 100644 index 0000000..c6d51ab --- /dev/null +++ b/pro/src/oauth2.ts @@ -0,0 +1,26 @@ +import { base64url } from "rfc4648"; + +/** + * https://datatracker.ietf.org/doc/html/rfc7636 + * dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk + * => E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + * @param x + * @returns BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) + */ +export async function codeVerifier2CodeChallenge(x: string) { + if (x === undefined || x === "") { + return ""; + } + try { + return base64url.stringify( + new Uint8Array( + await crypto.subtle.digest("SHA-256", new TextEncoder().encode(x)) + ), + { + pad: false, + } + ); + } catch (e) { + return ""; + } +} diff --git a/pro/src/settingsBox.ts b/pro/src/settingsBox.ts new file mode 100644 index 0000000..9954302 --- /dev/null +++ b/pro/src/settingsBox.ts @@ -0,0 +1,367 @@ +import cloneDeep from "lodash/cloneDeep"; +import { type App, Modal, Notice, Setting } from "obsidian"; +import { getClient } from "../../src/fsGetter"; +import type { TransItemType } from "../../src/i18n"; +import type RemotelySavePlugin from "../../src/main"; +import { stringToFragment } from "../../src/misc"; +import { ChangeRemoteBaseDirModal } from "../../src/settings"; +import { + DEFAULT_BOX_CONFIG, + generateAuthUrl, + sendRefreshTokenReq, +} from "./fsBox"; + +class BoxAuthModal extends Modal { + readonly plugin: RemotelySavePlugin; + readonly authDiv: HTMLDivElement; + readonly revokeAuthDiv: HTMLDivElement; + readonly revokeAuthSetting: Setting; + readonly t: (x: TransItemType, vars?: any) => string; + constructor( + app: App, + plugin: RemotelySavePlugin, + authDiv: HTMLDivElement, + revokeAuthDiv: HTMLDivElement, + revokeAuthSetting: Setting, + t: (x: TransItemType, vars?: any) => string + ) { + super(app); + this.plugin = plugin; + this.authDiv = authDiv; + this.revokeAuthDiv = revokeAuthDiv; + this.revokeAuthSetting = revokeAuthSetting; + this.t = t; + } + + async onOpen() { + const { contentEl } = this; + const t = this.t; + + const authUrl = generateAuthUrl(); + const div2 = contentEl.createDiv(); + div2.createDiv({ + text: stringToFragment(t("modal_boxauth_tutorial")), + }); + div2.createEl( + "button", + { + text: t("modal_boxauth_copybutton"), + }, + (el) => { + el.onclick = async () => { + await navigator.clipboard.writeText(authUrl); + new Notice(t("modal_boxauth_copynotice")); + }; + } + ); + + contentEl.createEl("p").createEl("a", { + href: authUrl, + text: authUrl, + }); + + // let refreshToken = ""; + // new Setting(contentEl) + // .setName(t("modal_box_maualinput")) + // .setDesc(t("modal_box_maualinput_desc")) + // .addText((text) => + // text + // .setPlaceholder("") + // .setValue("") + // .onChange((val) => { + // refreshToken = val.trim(); + // }) + // ) + // .addButton(async (button) => { + // button.setButtonText(t("submit")); + // button.onClick(async () => { + // new Notice(t("modal_box_maualinput_notice")); + + // try { + // if (this.plugin.settings.box === undefined) { + // this.plugin.settings.box = cloneDeep( + // DEFAULT_BOX_CONFIG + // ); + // } + // this.plugin.settings.box.refreshToken = refreshToken; + // this.plugin.settings.box.accessToken = "access"; + // this.plugin.settings.box.accessTokenExpiresAtTimeMs = 1; + // this.plugin.settings.box.accessTokenExpiresInMs = 1; + + // // TODO: abstraction leaking now, how to fix? + // const k = await sendRefreshTokenReq(refreshToken); + // const ts = Date.now(); + // this.plugin.settings.box.accessToken = k.access_token; + // this.plugin.settings.box.accessTokenExpiresInMs = + // k.expires_in * 1000; + // this.plugin.settings.box.accessTokenExpiresAtTimeMs = + // ts + k.expires_in * 1000 - 60 * 2 * 1000; + // await this.plugin.saveSettings(); + + // // try to remove data in clipboard + // await navigator.clipboard.writeText(""); + + // new Notice(t("modal_box_maualinput_succ_notice")); + // } catch (e) { + // console.error(e); + // new Notice(t("modal_box_maualinput_fail_notice")); + // } finally { + // this.authDiv.toggleClass( + // "box-auth-button-hide", + // this.plugin.settings.box.refreshToken !== "" + // ); + // this.revokeAuthDiv.toggleClass( + // "box-revoke-auth-button-hide", + // this.plugin.settings.box.refreshToken === "" + // ); + // this.close(); + // } + // }); + // }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + +class BoxRevokeAuthModal extends Modal { + readonly plugin: RemotelySavePlugin; + readonly authDiv: HTMLDivElement; + readonly revokeAuthDiv: HTMLDivElement; + readonly t: (x: TransItemType, vars?: any) => string; + constructor( + app: App, + plugin: RemotelySavePlugin, + authDiv: HTMLDivElement, + revokeAuthDiv: HTMLDivElement, + t: (x: TransItemType, vars?: any) => string + ) { + super(app); + this.plugin = plugin; + this.authDiv = authDiv; + this.revokeAuthDiv = revokeAuthDiv; + this.t = t; + } + + async onOpen() { + const t = this.t; + const { contentEl } = this; + + contentEl.createEl("p", { + text: t("modal_boxrevokeauth_step1"), + }); + const consentUrl = "https://app.box.com/account/security"; + contentEl.createEl("p").createEl("a", { + href: consentUrl, + text: consentUrl, + }); + + contentEl.createEl("p", { + text: t("modal_boxrevokeauth_step2"), + }); + + new Setting(contentEl) + .setName(t("modal_boxrevokeauth_clean")) + .setDesc(t("modal_boxrevokeauth_clean_desc")) + .addButton(async (button) => { + button.setButtonText(t("modal_boxrevokeauth_clean_button")); + button.onClick(async () => { + try { + this.plugin.settings.box = cloneDeep(DEFAULT_BOX_CONFIG); + + await this.plugin.saveSettings(); + this.authDiv.toggleClass( + "box-auth-button-hide", + this.plugin.settings.box.refreshToken !== "" + ); + this.revokeAuthDiv.toggleClass( + "box-revoke-auth-button-hide", + this.plugin.settings.box.refreshToken === "" + ); + new Notice(t("modal_boxrevokeauth_clean_notice")); + this.close(); + } catch (err) { + console.error(err); + new Notice(t("modal_boxrevokeauth_clean_fail")); + } + }); + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + +export const generateBoxSettingsPart = ( + containerEl: HTMLElement, + t: (x: TransItemType, vars?: any) => string, + app: App, + plugin: RemotelySavePlugin, + saveUpdatedConfigFunc: () => Promise | undefined +) => { + const boxDiv = containerEl.createEl("div", { + cls: "box-hide", + }); + boxDiv.toggleClass("box-hide", plugin.settings.serviceType !== "box"); + boxDiv.createEl("h2", { text: t("settings_box") }); + + const boxLongDescDiv = boxDiv.createEl("div", { + cls: "settings-long-desc", + }); + for (const c of [ + t("settings_box_disclaimer1"), + t("settings_box_disclaimer2"), + ]) { + boxLongDescDiv.createEl("p", { + text: c, + cls: "box-disclaimer", + }); + } + + boxLongDescDiv.createEl("p", { + text: t("settings_box_folder", { + remoteBaseDir: plugin.settings.box.remoteBaseDir || app.vault.getName(), + }), + }); + + boxLongDescDiv.createDiv({ + text: stringToFragment(t("settings_box_pro_desc")), + cls: "box-disclaimer", + }); + + const boxNotShowUpHintSetting = new Setting(boxDiv) + .setName(t("settings_box_notshowuphint")) + .setDesc(t("settings_box_notshowuphint_desc")) + .addButton(async (button) => { + button.setButtonText(t("settings_box_notshowuphint_view_pro")); + button.onClick(async () => { + window.location.href = "#settings-pro"; + }); + }); + + const boxAllowedToUsedDiv = boxDiv.createDiv(); + // if pro enabled, show up; otherwise hide. + const allowBox = + plugin.settings.pro?.enabledProFeatures.filter( + (x) => x.featureName === "feature-box" + ).length === 1; + console.debug(`allow to show up box settings? ${allowBox}`); + if (allowBox) { + boxAllowedToUsedDiv.removeClass("box-allow-to-use-hide"); + boxNotShowUpHintSetting.settingEl.addClass("box-allow-to-use-hide"); + } else { + boxAllowedToUsedDiv.addClass("box-allow-to-use-hide"); + boxNotShowUpHintSetting.settingEl.removeClass("box-allow-to-use-hide"); + } + + const boxSelectAuthDiv = boxAllowedToUsedDiv.createDiv(); + const boxAuthDiv = boxSelectAuthDiv.createDiv({ + cls: "box-auth-button-hide settings-auth-related", + }); + const boxRevokeAuthDiv = boxSelectAuthDiv.createDiv({ + cls: "box-revoke-auth-button-hide settings-auth-related", + }); + + const boxRevokeAuthSetting = new Setting(boxRevokeAuthDiv) + .setName(t("settings_box_revoke")) + .setDesc(t("settings_box_revoke_desc")) + .addButton(async (button) => { + button.setButtonText(t("settings_box_revoke_button")); + button.onClick(async () => { + new BoxRevokeAuthModal( + app, + plugin, + boxAuthDiv, + boxRevokeAuthDiv, + t + ).open(); + }); + }); + + new Setting(boxAuthDiv) + .setName(t("settings_box_auth")) + .setDesc(t("settings_box_auth_desc")) + .addButton(async (button) => { + button.setButtonText(t("settings_box_auth_button")); + button.onClick(async () => { + const modal = new BoxAuthModal( + app, + plugin, + boxAuthDiv, + boxRevokeAuthDiv, + boxRevokeAuthSetting, + t + ); + plugin.oauth2Info.helperModal = modal; + plugin.oauth2Info.authDiv = boxAuthDiv; + plugin.oauth2Info.revokeDiv = boxRevokeAuthDiv; + plugin.oauth2Info.revokeAuthSetting = boxRevokeAuthSetting; + modal.open(); + }); + }); + + boxAuthDiv.toggleClass( + "box-auth-button-hide", + plugin.settings.box.refreshToken !== "" + ); + boxRevokeAuthDiv.toggleClass( + "box-revoke-auth-button-hide", + plugin.settings.box.refreshToken === "" + ); + + let newboxRemoteBaseDir = plugin.settings.box.remoteBaseDir || ""; + new Setting(boxAllowedToUsedDiv) + .setName(t("settings_remotebasedir")) + .setDesc(t("settings_remotebasedir_desc")) + .addText((text) => + text + .setPlaceholder(app.vault.getName()) + .setValue(newboxRemoteBaseDir) + .onChange((value) => { + newboxRemoteBaseDir = value.trim(); + }) + ) + .addButton((button) => { + button.setButtonText(t("confirm")); + button.onClick(() => { + new ChangeRemoteBaseDirModal( + app, + plugin, + newboxRemoteBaseDir, + "box" + ).open(); + }); + }); + new Setting(boxAllowedToUsedDiv) + .setName(t("settings_checkonnectivity")) + .setDesc(t("settings_checkonnectivity_desc")) + .addButton(async (button) => { + button.setButtonText(t("settings_checkonnectivity_button")); + button.onClick(async () => { + new Notice(t("settings_checkonnectivity_checking")); + const client = getClient(plugin.settings, app.vault.getName(), () => + plugin.saveSettings() + ); + const errors = { msg: "" }; + const res = await client.checkConnect((err: any) => { + errors.msg = `${err}`; + }); + if (res) { + new Notice(t("settings_box_connect_succ")); + } else { + new Notice(t("settings_box_connect_fail")); + new Notice(errors.msg); + } + }); + }); + + return { + boxDiv: boxDiv, + boxAllowedToUsedDiv: boxAllowedToUsedDiv, + boxNotShowUpHintSetting: boxNotShowUpHintSetting, + }; +}; diff --git a/src/baseTypes.ts b/src/baseTypes.ts index b937c7d..6f0a040 100644 --- a/src/baseTypes.ts +++ b/src/baseTypes.ts @@ -3,7 +3,11 @@ * To avoid circular dependency. */ -import type { GoogleDriveConfig, ProConfig } from "../pro/src/baseTypesPro"; +import type { + BoxConfig, + GoogleDriveConfig, + ProConfig, +} from "../pro/src/baseTypesPro"; import type { LangTypeAndAuto } from "./i18n"; export const DEFAULT_CONTENT_TYPE = "application/octet-stream"; @@ -14,14 +18,16 @@ export type SUPPORTED_SERVICES_TYPE = | "dropbox" | "onedrive" | "webdis" - | "googledrive"; + | "googledrive" + | "box"; export type SUPPORTED_SERVICES_TYPE_WITH_REMOTE_BASE_DIR = | "webdav" | "dropbox" | "onedrive" | "webdis" - | "googledrive"; + | "googledrive" + | "box"; export interface S3Config { s3Endpoint: string; @@ -116,7 +122,8 @@ export type QRExportType = | "onedrive" | "webdav" | "webdis" - | "googledrive"; + | "googledrive" + | "box"; export interface ProfilerConfig { enablePrinting?: boolean; @@ -130,6 +137,7 @@ export interface RemotelySavePluginSettings { onedrive: OnedriveConfig; webdis: WebdisConfig; googledrive: GoogleDriveConfig; + box: BoxConfig; password: string; serviceType: SUPPORTED_SERVICES_TYPE; currLogLevel?: string; diff --git a/src/fsGetter.ts b/src/fsGetter.ts index 62ec9b5..ea473e1 100644 --- a/src/fsGetter.ts +++ b/src/fsGetter.ts @@ -1,3 +1,4 @@ +import { FakeFsBox } from "../pro/src/fsBox"; import { FakeFsGoogleDrive } from "../pro/src/fsGoogleDrive"; import type { RemotelySavePluginSettings } from "./baseTypes"; import type { FakeFs } from "./fsAll"; @@ -48,6 +49,8 @@ export function getClient( vaultName, saveUpdatedConfigFunc ); + case "box": + return new FakeFsBox(settings.box, vaultName, saveUpdatedConfigFunc); default: throw Error(`cannot init client for serviceType=${settings.serviceType}`); } diff --git a/src/importExport.ts b/src/importExport.ts index 4cf1427..66b5949 100644 --- a/src/importExport.ts +++ b/src/importExport.ts @@ -25,6 +25,7 @@ export const exportQrCodeUri = async ( delete settings2.webdav; delete settings2.webdis; delete settings2.googledrive; + delete settings2.box; delete settings2.pro; } else if (exportFields === "s3") { settings2 = { s3: cloneDeep(settings.s3) }; @@ -38,6 +39,8 @@ export const exportQrCodeUri = async ( settings2 = { webdis: cloneDeep(settings.webdis) }; } else if (exportFields === "googledrive") { settings2 = { googledrive: cloneDeep(settings.googledrive) }; + } else if (exportFields === "box") { + settings2 = { box: cloneDeep(settings.box) }; } delete settings2.vaultRandomID; diff --git a/src/main.ts b/src/main.ts index 1c73400..fa81e97 100644 --- a/src/main.ts +++ b/src/main.ts @@ -63,7 +63,16 @@ import { SyncAlgoV3Modal } from "./syncAlgoV3Notice"; // biome-ignore lint/suspicious/noShadowRestrictedNames: import AggregateError from "aggregate-error"; import throttle from "lodash/throttle"; -import { COMMAND_CALLBACK_PRO } from "../pro/src/baseTypesPro"; +import { + COMMAND_CALLBACK_BOX, + COMMAND_CALLBACK_PRO, +} from "../pro/src/baseTypesPro"; +import { + DEFAULT_BOX_CONFIG, + FakeFsBox, + sendAuthReq as sendAuthReqBox, + setConfigBySuccessfullAuthInplace as setConfigBySuccessfullAuthInplaceBox, +} from "../pro/src/fsBox"; import { DEFAULT_GOOGLEDRIVE_CONFIG } from "../pro/src/fsGoogleDrive"; import { exportVaultSyncPlansToFiles } from "./debugMode"; import { FakeFsEncrypt } from "./fsEncrypt"; @@ -81,6 +90,7 @@ const DEFAULT_SETTINGS: RemotelySavePluginSettings = { onedrive: DEFAULT_ONEDRIVE_CONFIG, webdis: DEFAULT_WEBDIS_CONFIG, googledrive: DEFAULT_GOOGLEDRIVE_CONFIG, + box: DEFAULT_BOX_CONFIG, password: "", serviceType: "s3", currLogLevel: "info", @@ -782,6 +792,62 @@ export default class RemotelySavePlugin extends Plugin { } ); + this.registerObsidianProtocolHandler( + COMMAND_CALLBACK_BOX, + async (inputParams) => { + if (this.oauth2Info.helperModal !== undefined) { + const k = this.oauth2Info.helperModal.contentEl; + k.empty(); + + t("protocol_box_connecting") + .split("\n") + .forEach((val) => { + k.createEl("p", { + text: val, + }); + }); + } + + console.debug(inputParams); + const authRes = await sendAuthReqBox( + inputParams.code, + async (e: any) => { + new Notice(t("protocol_box_connect_fail")); + new Notice(`${e}`); + throw e; + } + ); + console.debug(authRes); + + const self = this; + await setConfigBySuccessfullAuthInplaceBox( + this.settings.box!, + authRes, + () => self.saveSettings() + ); + + this.oauth2Info.verifier = ""; // reset it + this.oauth2Info.helperModal?.close(); // close it + this.oauth2Info.helperModal = undefined; + + this.oauth2Info.authDiv?.toggleClass( + "box-auth-button-hide", + this.settings.box?.refreshToken !== "" + ); + this.oauth2Info.authDiv = undefined; + + this.oauth2Info.revokeAuthSetting?.setDesc( + t("protocol_box_connect_succ_revoke") + ); + this.oauth2Info.revokeAuthSetting = undefined; + this.oauth2Info.revokeDiv?.toggleClass( + "box-revoke-auth-button-hide", + this.settings.box?.refreshToken === "" + ); + this.oauth2Info.revokeDiv = undefined; + } + ); + this.syncRibbon = this.addRibbonIcon( iconNameSyncWait, `${this.manifest.name}`, @@ -1068,6 +1134,10 @@ export default class RemotelySavePlugin extends Plugin { this.settings.googledrive = DEFAULT_GOOGLEDRIVE_CONFIG; } + if (this.settings.box === undefined) { + this.settings.box = DEFAULT_BOX_CONFIG; + } + await this.saveSettings(); } @@ -1136,6 +1206,26 @@ export default class RemotelySavePlugin extends Plugin { needSave = true; } + let googleDriveExpired = false; + if ( + this.settings.googledrive.refreshToken !== "" && + current >= this.settings!.googledrive!.credentialsShouldBeDeletedAtTimeMs! + ) { + googleDriveExpired = true; + this.settings.googledrive = cloneDeep(DEFAULT_GOOGLEDRIVE_CONFIG); + needSave = true; + } + + let boxExpired = false; + if ( + this.settings.box.refreshToken !== "" && + current >= this.settings!.box!.credentialsShouldBeDeletedAtTimeMs! + ) { + boxExpired = true; + this.settings.box = cloneDeep(DEFAULT_BOX_CONFIG); + needSave = true; + } + if (this.settings.pro === undefined) { this.settings.pro = cloneDeep(DEFAULT_PRO_CONFIG); } diff --git a/src/misc.ts b/src/misc.ts index 2650f60..ad78ed8 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -716,3 +716,14 @@ export const splitFileSizeToChunkRanges = ( } return res; }; + +export const getSha1 = async (x: ArrayBuffer, stringify: "base64" | "hex") => { + const y = await window.crypto.subtle.digest("SHA-1", x); + + if (stringify === "base64") { + return arrayBufferToBase64(y); + } else if (stringify === "hex") { + return arrayBufferToHex(y); + } + throw Error(`not supported stringify option = ${stringify}`); +}; diff --git a/src/settings.ts b/src/settings.ts index 9c56ebf..6a311bc 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -21,6 +21,7 @@ import type { } from "./baseTypes"; import cloneDeep from "lodash/cloneDeep"; +import { generateBoxSettingsPart } from "../pro/src/settingsBox"; import { generateGoogleDriveSettingsPart } from "../pro/src/settingsGoogleDrive"; import { generateProSettingsPart } from "../pro/src/settingsPro"; import { API_VER_ENSURE_REQURL_OK, VALID_REQURL } from "./baseTypesObs"; @@ -1808,6 +1809,15 @@ export class RemotelySaveSettingTab extends PluginSettingTab { () => this.plugin.saveSettings() ); + ////////////////////////////////////////////////// + // below for box + ////////////////////////////////////////////////// + + const { boxDiv, boxAllowedToUsedDiv, boxNotShowUpHintSetting } = + generateBoxSettingsPart(containerEl, t, this.app, this.plugin, () => + this.plugin.saveSettings() + ); + ////////////////////////////////////////////////// // below for general chooser (part 2/2) ////////////////////////////////////////////////// @@ -1827,6 +1837,7 @@ export class RemotelySaveSettingTab extends PluginSettingTab { "googledrive", t("settings_chooseservice_googledrive") ); + dropdown.addOption("box", t("settings_chooseservice_box")); dropdown .setValue(this.plugin.settings.serviceType) @@ -1856,6 +1867,10 @@ export class RemotelySaveSettingTab extends PluginSettingTab { "googledrive-hide", this.plugin.settings.serviceType !== "googledrive" ); + boxDiv.toggleClass( + "box-hide", + this.plugin.settings.serviceType !== "box" + ); await this.plugin.saveSettings(); }); }); @@ -2418,6 +2433,12 @@ export class RemotelySaveSettingTab extends PluginSettingTab { "googledrive" ).open(); }); + }) + .addButton(async (button) => { + button.setButtonText(t("settings_export_box_button")); + button.onClick(async () => { + new ExportSettingsQrCodeModal(this.app, this.plugin, "box").open(); + }); }); let importSettingVal = ""; diff --git a/src/sync.ts b/src/sync.ts index ef183b1..c196416 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1507,12 +1507,7 @@ export async function syncer( try { // check pro feature // if anything goes wrong, it will throw - await checkProRunnableAndFixInplace( - ["feature-smart_conflict", "feature-google_drive"], - settings, - pluginVersion, - configSaver - ); + await checkProRunnableAndFixInplace(settings, pluginVersion, configSaver); // try mode? await notifyFunc?.(triggerSource, step); diff --git a/styles.css b/styles.css index 7997d08..8181174 100644 --- a/styles.css +++ b/styles.css @@ -91,6 +91,25 @@ display: none; } +.box-disclaimer { + font-weight: bold; +} +.box-hide { + display: none; +} + +.box-allow-to-use-hide { + display: none; +} + +.box-auth-button-hide { + display: none; +} + +.box-revoke-auth-button-hide { + display: none; +} + .qrcode-img { width: 350px; height: 350px; diff --git a/tests/configPersist.test.ts b/tests/configPersist.test.ts index 82353ae..fc54c7d 100644 --- a/tests/configPersist.test.ts +++ b/tests/configPersist.test.ts @@ -22,6 +22,9 @@ const DEFAULT_SETTINGS: RemotelySavePluginSettings = { googledrive: { refreshToken: "xxx", } as any, + box: { + refreshToken: "xxx", + } as any, password: "password", serviceType: "s3", currLogLevel: "info", diff --git a/webpack.config.js b/webpack.config.js index c93e797..748433b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -11,6 +11,8 @@ const DEFAULT_REMOTELYSAVE_CLIENT_ID = process.env.REMOTELYSAVE_CLIENT_ID || ""; const DEFAULT_GOOGLEDRIVE_CLIENT_ID = process.env.GOOGLEDRIVE_CLIENT_ID || ""; const DEFAULT_GOOGLEDRIVE_CLIENT_SECRET = process.env.GOOGLEDRIVE_CLIENT_SECRET || ""; +const DEFAULT_BOX_CLIENT_ID = process.env.BOX_CLIENT_ID || ""; +const DEFAULT_BOX_CLIENT_SECRET = process.env.BOX_CLIENT_SECRET || ""; module.exports = { entry: "./src/main.ts", @@ -29,6 +31,8 @@ module.exports = { "process.env.DEFAULT_REMOTELYSAVE_CLIENT_ID": `"${DEFAULT_REMOTELYSAVE_CLIENT_ID}"`, "process.env.DEFAULT_GOOGLEDRIVE_CLIENT_ID": `"${DEFAULT_GOOGLEDRIVE_CLIENT_ID}"`, "process.env.DEFAULT_GOOGLEDRIVE_CLIENT_SECRET": `"${DEFAULT_GOOGLEDRIVE_CLIENT_SECRET}"`, + "process.env.DEFAULT_BOX_CLIENT_ID": `"${DEFAULT_BOX_CLIENT_ID}"`, + "process.env.DEFAULT_BOX_CLIENT_SECRET": `"${DEFAULT_BOX_CLIENT_SECRET}"`, }), // Work around for Buffer is undefined: // https://github.com/webpack/changelog-v5/issues/10