Compare commits

...
30 Commits
Author SHA1 Message Date
fyears 9a1b11b811 0.2.7 2022-01-03 01:04:45 +08:00
fyears 3d6746ed99 quick fix for onedrive path 2022-01-03 01:03:56 +08:00
fyears 2a797f8c40 force refresh version download 2022-01-01 23:58:25 +08:00
fyears 0f7b13ec27 add download stats for new version 2022-01-01 23:54:14 +08:00
fyears abd124b155 bump to 0.2.6 2022-01-01 23:50:28 +08:00
fyears 2a42abb9cc fix desc for qrcode 2022-01-01 23:49:34 +08:00
fyears e9bb61ced4 correct check for webdav 2022-01-01 23:47:00 +08:00
fyears 5b703334d7 change qrcode 2022-01-01 23:26:01 +08:00
fyears 4639b93cfa add more bad cases for webdav 2022-01-01 18:49:10 +08:00
fyears 92f12cd5f4 more disclaimer for password 2022-01-01 18:48:25 +08:00
fyears 72417cd075 add OneDrive in README 2022-01-01 18:44:14 +08:00
fyears f8a930eb4e change desc of onedrive 2022-01-01 18:43:25 +08:00
fyears 6832c9f649 force expire login cred 2022-01-01 18:37:48 +08:00
fyears 34d1b01c7b optimize onedrive revoke flow 2022-01-01 14:16:37 +08:00
fyears f26ee8bbc5 optimize dropbox flow 2022-01-01 13:55:27 +08:00
fyears 414a3f5e4a more notice for error 2022-01-01 00:56:32 +08:00
fyears 02e5f9b5a0 fix onedrive auth process 2022-01-01 00:53:44 +08:00
fyears d53569fe71 polish titles and comments 2022-01-01 00:49:51 +08:00
fyears 4865a797aa assign default const 2022-01-01 00:40:54 +08:00
fyears 5a5898f315 fix onedrive auth hint 2021-12-31 23:54:48 +08:00
fyears d5ba0a0e0d split settings and fix format 2021-12-31 23:46:52 +08:00
fyears dde7d9a732 fix enc base64url doc test 2021-12-31 20:55:13 +08:00
fyears 1bc6ba4b01 use base64url not base32 2021-12-31 01:00:52 +08:00
fyears 792703f8b6 fix progress count 2021-12-30 01:30:53 +08:00
fyears 6552fd32d1 basically working onedrive 2021-12-29 00:35:46 +08:00
fyears f3e6bc2fdd fix readme 2021-12-29 00:34:16 +08:00
fyears 63d52df378 add direct link to repo 2021-12-25 11:58:58 +08:00
fyears 8833532370 add badges 2021-12-18 00:31:04 +08:00
fyears 8cca96e138 0.2.5 2021-12-12 01:21:17 +08:00
fyears 804afabdeb rm some webdav logs 2021-12-12 01:20:38 +08:00
22 changed files with 2297 additions and 884 deletions
+26 -9
View File
@@ -1,6 +1,8 @@
# Remotely Save
This is yet another unofficial sync plugin for Obsidian.
This is yet another unofficial sync plugin for Obsidian. If you like it or find it useful, please consider give it a [star ![GitHub Repo stars](https://img.shields.io/github/stars/fyears/remotely-save?style=social)](https://github.com/fyears/remotely-save) on Github.
[![BuildCI](https://github.com/fyears/remotely-save/actions/workflows/auto-build.yml/badge.svg)](https://github.com/fyears/remotely-save/actions/workflows/auto-build.yml) [![Latest release: 0.2.6)](https://img.shields.io/github/downloads/fyears/remotely-save/0.2.6/main.js?sort=semver)](https://github.com/fyears/remotely-save/releases) [![Previous release: 0.2.5)](https://img.shields.io/github/downloads/fyears/remotely-save/0.2.5/main.js)](https://github.com/fyears/remotely-save/releases)
## Disclaimer
@@ -8,13 +10,14 @@ This is yet another unofficial sync plugin for Obsidian.
## !!!Caution!!!
As of Dec 2021, the plugin is considered in BETA stage. **DO NOT USE IT for any serious vaults.** **ALWAYS, ALWAYS, backup your vault before using this plugin.**
As of Jan 2022, the plugin is considered in BETA stage. **DO NOT USE IT for any serious vaults.** **ALWAYS, ALWAYS, backup your vault before using this plugin.**
## Features
- Supports:
- Amazon S3 or S3-compatible
- Dropbox
- OneDrive
- Webdav
- **Obsidiain Mobile supported.** Vaults can be synced across mobile and desktop devices with the cloud service as the "broker".
- **[End-to-end encryption](./docs/encryption.md) supported.** Files would be encrypted using openssl format before being sent to the cloud **if** user specify a password.
@@ -43,8 +46,9 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
## Download and Install
- Option #1: You can search, download, and install the plugin in official "community plugin list".
- Option #2: [![BuildCI](https://github.com/fyears/remotely-save/actions/workflows/auto-build.yml/badge.svg)](https://github.com/fyears/remotely-save/actions/workflows/auto-build.yml) Every artifacts are placed in the "Summary" under every successful builds.
- Option #3: Besides manually downloading the files, you can also use [Obsidian42 - BRAT](https://github.com/TfTHacker/obsidian42-brat) to install this plugin.
- Option #2: You can also use [Obsidian42 - BRAT](https://github.com/TfTHacker/obsidian42-brat) to install this plugin. Input `fyears/remotely-save` in the configuration of BRAT.
- Option #3: [![GitHub release (latest by SemVer and asset including pre-releases)](https://img.shields.io/github/downloads-pre/fyears/remotely-save/latest/main.js?sort=semver)](https://github.com/fyears/remotely-save/releases) Manually download assets (`main.js`, `manifest.json`, `styles.css`) from the latest release.
- Option #4: [![BuildCI](https://github.com/fyears/remotely-save/actions/workflows/auto-build.yml/badge.svg)](https://github.com/fyears/remotely-save/actions/workflows/auto-build.yml) Every artifacts are placed in the "Summary" under every successful builds. It's automatically generated by every commit, may break something.
## Usage
@@ -62,25 +66,38 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
- **This plugin's function for Dropbox is not as mature as functions for S3.**
- **This plugin is NOT an official Dropbox product.** The plugin just uses Dropbox's public API.
- After the authorization, the plugin can read your name and email (which cannot be unselected on Dropbox api), and read and write files in your Dropbox's `/Apps/remotely-save/${vaultName}` folder.
- After the authorization, the plugin can read your name and email (which cannot be unselected on Dropbox api), and read and write files in your Dropbox's `/Apps/remotely-save` folder.
- If you decide to authorize this plugin to connect to Dropbox, please go to plugin's settings, and choose Dropbox then follow the instructions.
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
### OneDrive
- **This plugin's function for OneDrive is not as mature as functions for S3.**
- **This plugin is NOT an official Microsoft / OneDrive product.** The plugin just uses Microsoft's [OneDrive's public API](https://docs.microsoft.com/en-us/onedrive/developer/rest-api).
- After the authorization, the plugin can read your name and email, and read and write files in your OneDrive's `/Apps/remotely-save` folder.
- If you decide to authorize this plugin to connect to OneDrive, please go to plugin's settings, and choose OneDrive then follow the instructions.
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
### webdav
- **webdav support is considered experimental.**
- Currently only supports BASIC authorization method.
- Currently webdav server has to be enabled CORS for requests from `app://obsidian.md` and `capacitor://localhost` and `http://localhost`, **AND** all webdav HTTP methods, **AND** all webdav headers. These are required, because Obsidian mobile works like a browser and mobile plugins are limited by CORS policies.
- Popular software NextCloud and OwnCloud do **NOT** enable CORS by default. If you are using any of them, you should evaluate the risk, and find a way to enable CORS, before using this plugin.
- Popular software NextCloud, OwnCloud, `rclone serve webdav` do **NOT** enable CORS by default. If you are using any of them, you should evaluate the risk, and find a way to enable CORS, before using this plugin.
- The plugin is tested successfully under python package [`wsgidav` (version 4.0)](https://github.com/mar10/wsgidav). See [this issue](https://github.com/mar10/wsgidav/issues/239) for some details.
- Your data would be synced to a `${vaultName}` sub folder on your webdav server.
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
## Import And Export Plugin Settings By QR Code
## Import And Export Not-Oauth2 Plugin Settings By QR Code
It's often verbose / inconvenient to input credentials on mobile. (On different desktop computers, it's trivial to copy and paste `data.json` across different devices.)
It's often verbose / inconvenient to input not-oauth2 credentials on mobile. (On different desktop computers, it's trivial to copy and paste `data.json` across different devices.)
So this plugin has a helper function to export settings as a QR code, then you could use mobile device's camera to import the settings.
So this plugin has a helper function to export those settings as a QR code, then you could use mobile device's camera to import the settings.
Attention:
1. Oauth2 - related information is omitted. It means that Dropbox, OneDrive login credentials are NOT included in the QR Code.
2. Please, NEVER share the QR Code to others. It's as equivalent to the login credentials.
Steps:
+10 -5
View File
@@ -15,15 +15,20 @@ The encryption algorithm is delibrately designed to be aligned with openssl form
openssl enc -d -p -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:somepassword -in ./sometext.txt.enc -out ./sometext.txt
```
3. The file/directory path strings, are encrypted using openssl in binary mode and then `base32` is applied.
3. The file/directory path strings, are encrypted using openssl in binary mode and then `base64url without padding` is applied.
Assuming the file path is `a-folder-文件夹/a-file-文件.md`, then the following commands are equivilent:
```bash
# pure string encryption then base32
echo -n 'a-folder-文件夹/a-file-文件.md' | openssl enc -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword | base32 -w 0
# prepare the functions
# https://unix.stackexchange.com/questions/628842
base64url::encode () { base64 -w0 | tr '+/' '-_' | tr -d '='; }
base64url::decode () { awk '{ if (length($0) % 4 == 3) print $0"="; else if (length($0) % 4 == 2) print $0"=="; else print $0; }' | tr -- '-_' '+/' | base64 -d; }
# pure string base32 then decryption
echo -n 'KNQWY5DFMRPV7UHRWVYFSHE2XVVVZCFN65SR7ETEKO5L6EYGXCVEPT4A2LVTW4W2ZHXWF3K22SVA562CCZ6SALARXJY6AAXXHLK5UOA=' | base32 -d -w 00 | openssl enc -d -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword
# pure string encryption then base32
echo -n 'a-folder-文件夹/a-file-文件.md' | openssl enc -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword | base64url::encode
# pure string base64url then decryption
echo -n 'U2FsdGVkX19tNkdFL5rZeHxbe7FL-Pp5mkZJkDNFJWFT6lldZlfa57j0C_cKn0I3PZ9YDvOkyoKqfF6lbn0_yg' | base64url::decode | openssl enc -d -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword
```
4. The directory is considered as special "0-byte" object on remote s3. So this meta infomation may be easily guessed if some third party can access the remote bucket.
+4
View File
@@ -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));
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "remotely-save",
"name": "Remotely Save",
"version": "0.2.4",
"version": "0.2.7",
"minAppVersion": "0.12.15",
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
"author": "fyears",
+8 -2
View File
@@ -1,6 +1,6 @@
{
"name": "remotely-save",
"version": "0.2.4",
"version": "0.2.7",
"description": "This is yet another sync plugin for Obsidian app.",
"scripts": {
"dev": "node esbuild.config.mjs",
@@ -13,16 +13,19 @@
"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",
"@types/lodash": "^4.14.178",
"@types/mime-types": "^2.1.1",
"@types/mocha": "^9.0.0",
"@types/node": "^14.14.37",
@@ -48,6 +51,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",
@@ -56,6 +61,7 @@
"crypto-browserify": "^3.12.0",
"dropbox": "^10.22.0",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"mime-types": "^2.1.33",
"obsidian": "^0.12.0",
"path-browserify": "^1.0.1",
+20 -1
View File
@@ -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;
@@ -21,6 +21,7 @@ export interface DropboxConfig {
accessTokenExpiresAtTime: number;
accountID: string;
username: string;
credentialsShouldBeDeletedAtTime?: number;
}
export type WebdavAuthType = "digest" | "basic";
@@ -32,10 +33,23 @@ export interface WebdavConfig {
authType: WebdavAuthType;
}
export interface OnedriveConfig {
accessToken: string;
clientID: string;
authority: string;
refreshToken: string;
accessTokenExpiresInSeconds: number;
accessTokenExpiresAtTime: number;
deltaLink: string;
username: string;
credentialsShouldBeDeletedAtTime?: number;
}
export interface RemotelySavePluginSettings {
s3: S3Config;
webdav: WebdavConfig;
dropbox: DropboxConfig;
onedrive: OnedriveConfig;
password: string;
serviceType: SUPPORTED_SERVICES_TYPE;
}
@@ -50,6 +64,8 @@ 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 const COMMAND_CALLBACK_DROPBOX = "remotely-save-cb-dropbox";
export interface UriParams {
func?: string;
@@ -57,3 +73,6 @@ export interface UriParams {
ver?: string;
data?: string;
}
// 80 days
export const OAUTH2_FORCE_EXPIRE_MILLISECONDS = 1000 * 60 * 60 * 24 * 80;
+35 -9
View File
@@ -1,15 +1,12 @@
import { base32, base64 } from "rfc4648";
import {
bufferToArrayBuffer,
arrayBufferToBuffer,
hexStringToTypedArray,
arrayBufferToHex,
} from "./misc";
import { base32, base64url } from "rfc4648";
import { bufferToArrayBuffer, hexStringToTypedArray } from "./misc";
const DEFAULT_ITER = 20000;
// base32.stringify(Buffer.from('Salted__'))
export const MAGIC_ENCRYPTED_PREFIX_BASE32 = "KNQWY5DFMRPV";
// base64.stringify(Buffer.from('Salted__'))
export const MAGIC_ENCRYPTED_PREFIX_BASE64URL = "U2FsdGVkX";
const getKeyIVFromPassword = async (
salt: Uint8Array,
@@ -120,7 +117,7 @@ export const encryptStringToBase32 = async (
rounds,
saltHex
);
return base32.stringify(new Uint8Array(enc));
return base32.stringify(new Uint8Array(enc), { pad: false });
};
export const decryptBase32ToString = async (
@@ -130,7 +127,36 @@ export const decryptBase32ToString = async (
) => {
return new TextDecoder().decode(
await decryptArrayBuffer(
bufferToArrayBuffer(base32.parse(text)),
bufferToArrayBuffer(base32.parse(text, { loose: true })),
password,
rounds
)
);
};
export const encryptStringToBase64url = async (
text: string,
password: string,
rounds: number = DEFAULT_ITER,
saltHex: string = ""
) => {
const enc = await encryptArrayBuffer(
bufferToArrayBuffer(new TextEncoder().encode(text)),
password,
rounds,
saltHex
);
return base64url.stringify(new Uint8Array(enc), { pad: false });
};
export const decryptBase64urlToString = async (
text: string,
password: string,
rounds: number = DEFAULT_ITER
) => {
return new TextDecoder().decode(
await decryptArrayBuffer(
bufferToArrayBuffer(base64url.parse(text, { loose: true })),
password,
rounds
)
+5 -1
View File
@@ -1,4 +1,5 @@
import QRCode from "qrcode";
import cloneDeep from "lodash/cloneDeep";
import {
COMMAND_URI,
@@ -11,7 +12,10 @@ export const exportQrCodeUri = async (
currentVaultName: string,
pluginVersion: string
) => {
const data = encodeURIComponent(JSON.stringify(settings));
const settings2 = cloneDeep(settings);
delete settings2.dropbox;
delete settings2.onedrive;
const data = encodeURIComponent(JSON.stringify(settings2));
const vault = encodeURIComponent(currentVaultName);
const version = encodeURIComponent(pluginVersion);
const rawUri = `obsidian://${COMMAND_URI}?func=settings&version=${version}&vault=${vault}&data=${data}`;
+281 -767
View File
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -1,7 +1,7 @@
import { Vault } from "obsidian";
import * as path from "path";
import { base32 } from "rfc4648";
import { base32, base64url } from "rfc4648";
import XRegExp from "xregexp";
/**
@@ -114,6 +114,14 @@ export const base64ToBase32 = (a: string) => {
return base32.stringify(Buffer.from(a, "base64"));
};
export const base64ToBase64url = (a: string, pad: boolean = false) => {
let b = a.replace(/\+/g, "-").replace(/\//g, "_");
if (!pad) {
b = b.replace(/=/g, "");
}
return b;
};
/**
* iOS Safari could decrypt string with invalid password!
* So we need an extra way to test the decrypted result.
@@ -163,3 +171,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);
};
+57 -4
View File
@@ -1,14 +1,15 @@
import { Vault } from "obsidian";
import type {
SUPPORTED_SERVICES_TYPE,
S3Config,
DropboxConfig,
OnedriveConfig,
S3Config,
SUPPORTED_SERVICES_TYPE,
WebdavConfig,
} from "./baseTypes";
import * as dropbox from "./remoteForDropbox";
import * as onedrive from "./remoteForOnedrive";
import * as s3 from "./remoteForS3";
import * as webdav from "./remoteForWebdav";
import * as dropbox from "./remoteForDropbox";
export class RemoteClient {
readonly serviceType: SUPPORTED_SERVICES_TYPE;
@@ -18,12 +19,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<any>
) {
@@ -51,6 +55,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 +83,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 +130,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 +152,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 +194,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 +235,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 +254,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 +264,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}`);
}
+18 -14
View File
@@ -1,20 +1,18 @@
import { Dropbox, DropboxAuth, files } from "dropbox";
import { Vault } from "obsidian";
import * as path from "path";
import { FileStats, Vault } from "obsidian";
import { Dropbox, DropboxAuth, DropboxResponse, files } from "dropbox";
export { Dropbox } from "dropbox";
import { RemoteItem, DropboxConfig } from "./baseTypes";
import {
arrayBufferToBuffer,
bufferToArrayBuffer,
mkdirpInVault,
getPathFolder,
getFolderLevels,
setToString,
} from "./misc";
DropboxConfig,
RemoteItem,
COMMAND_CALLBACK_DROPBOX,
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
} from "./baseTypes";
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
import { bufferToArrayBuffer, getFolderLevels, mkdirpInVault } from "./misc";
export const DEFAULT_DROPBOX_CONFIG = {
export { Dropbox } from "dropbox";
export const DEFAULT_DROPBOX_CONFIG: DropboxConfig = {
accessToken: "",
clientID: process.env.DEFAULT_DROPBOX_APP_KEY,
refreshToken: "",
@@ -22,6 +20,7 @@ export const DEFAULT_DROPBOX_CONFIG = {
accessTokenExpiresAtTime: 0,
accountID: "",
username: "",
credentialsShouldBeDeletedAtTime: 0,
};
export const getDropboxPath = (fileOrFolderPath: string, vaultName: string) => {
@@ -160,7 +159,7 @@ export const getAuthUrlAndVerifier = async (appKey: string) => {
});
const authUrl = (
await auth.getAuthenticationUrl(
undefined,
`obsidian://${COMMAND_CALLBACK_DROPBOX}`,
undefined,
"code",
"offline",
@@ -198,6 +197,7 @@ export const sendAuthReq = async (
grant_type: "authorization_code",
code_verifier: verifier,
client_id: appKey,
redirect_uri: `obsidian://${COMMAND_CALLBACK_DROPBOX}`,
}),
});
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
@@ -234,6 +234,10 @@ export const setConfigBySuccessfullAuthInplace = async (
config.accessTokenExpiresAtTime =
Date.now() + parseInt(authRes.expires_in) * 1000 - 10 * 1000;
// manually set it expired after 80 days;
config.credentialsShouldBeDeletedAtTime =
Date.now() + OAUTH2_FORCE_EXPIRE_MILLISECONDS;
if (authRes.refresh_token !== undefined) {
config.refreshToken = authRes.refresh_token;
}
+737
View File
@@ -0,0 +1,737 @@
import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
import {
AuthenticationProvider,
Client,
FileUpload,
LargeFileUploadSession,
LargeFileUploadTask,
LargeFileUploadTaskOptions,
Range,
UploadEventHandlers,
UploadResult,
} from "@microsoft/microsoft-graph-client";
import type { DriveItem, User } from "@microsoft/microsoft-graph-types";
import cloneDeep from "lodash/cloneDeep";
import { request, Vault } from "obsidian";
import * as path from "path";
import {
DropboxConfig,
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
OnedriveConfig,
RemoteItem,
} from "./baseTypes";
import { COMMAND_CALLBACK_ONEDRIVE } from "./baseTypes";
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
import {
getRandomArrayBuffer,
getRandomIntInclusive,
mkdirpInVault,
} from "./misc";
const SCOPES = ["User.Read", "Files.ReadWrite.AppFolder", "offline_access"];
const REDIRECT_URI = `obsidian://${COMMAND_CALLBACK_ONEDRIVE}`;
export const DEFAULT_ONEDRIVE_CONFIG: OnedriveConfig = {
accessToken: "",
clientID: process.env.DEFAULT_ONEDRIVE_CLIENT_ID,
authority: process.env.DEFAULT_ONEDRIVE_AUTHORITY,
refreshToken: "",
accessTokenExpiresInSeconds: 0,
accessTokenExpiresAtTime: 0,
deltaLink: "",
username: "",
credentialsShouldBeDeletedAtTime: 0,
};
////////////////////////////////////////////////////////////////////////////////
// Onedrive authorization using PKCE
////////////////////////////////////////////////////////////////////////////////
export async function getAuthUrlAndVerifier(
clientID: string,
authority: string
) {
const cryptoProvider = new CryptoProvider();
const { verifier, challenge } = await cryptoProvider.generatePkceCodes();
const pkceCodes = {
challengeMethod: "S256", // Use SHA256 Algorithm
verifier: verifier,
challenge: challenge,
};
const authCodeUrlParams = {
redirectUri: REDIRECT_URI,
scopes: SCOPES,
codeChallenge: pkceCodes.challenge, // PKCE Code Challenge
codeChallengeMethod: pkceCodes.challengeMethod, // PKCE Code Challenge Method
};
const pca = new PublicClientApplication({
auth: {
clientId: clientID,
authority: authority,
},
});
const authCodeUrl = await pca.getAuthCodeUrl(authCodeUrlParams);
return {
authUrl: authCodeUrl,
verifier: verifier,
};
}
/**
* Check doc from
* https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
* https://docs.microsoft.com/en-us/onedrive/developer/rest-api/getting-started/graph-oauth?view=odsp-graph-online#code-flow
*/
export interface AccessCodeResponseSuccessfulType {
token_type: "Bearer" | "bearer";
expires_in: number;
ext_expires_in?: number;
scope: string;
access_token: string;
refresh_token?: string;
id_token?: string;
}
export interface AccessCodeResponseFailedType {
error: string;
error_description: string;
error_codes: number[];
timestamp: string;
trace_id: string;
correlation_id: string;
}
export const sendAuthReq = async (
clientID: string,
authority: string,
authCode: string,
verifier: string
) => {
// // 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;
}
};
export const setConfigBySuccessfullAuthInplace = async (
config: OnedriveConfig,
authRes: AccessCodeResponseSuccessfulType,
saveUpdatedConfigFunc: () => Promise<any> | undefined
) => {
console.log("start updating local info of OneDrive token");
config.accessToken = authRes.access_token;
config.accessTokenExpiresAtTime =
Date.now() + authRes.expires_in - 5 * 60 * 1000;
config.accessTokenExpiresInSeconds = authRes.expires_in;
config.refreshToken = authRes.refresh_token;
// manually set it expired after 80 days;
config.credentialsShouldBeDeletedAtTime =
Date.now() + OAUTH2_FORCE_EXPIRE_MILLISECONDS;
if (saveUpdatedConfigFunc !== undefined) {
await saveUpdatedConfigFunc();
}
console.log("finish updating local info of Onedrive token");
};
////////////////////////////////////////////////////////////////////////////////
// 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 constructFromDriveItemToRemoteItemError = (x: DriveItem) => {
return `parentPath="${x.parentReference.path}", selfName="${x.name}"`;
};
const fromDriveItemToRemoteItem = (
x: DriveItem,
vaultName: string
): RemoteItem => {
let key = "";
// possible prefix:
// pure english: /drive/root:/Apps/remotely-save/${vaultName}
// or localized, e.g.: /drive/root:/应用/remotely-save/${vaultName}
const FIRST_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/remotely-save\//g;
// another possibile prefix
const SECOND_COMMON_PREFIX_RAW = `/drive/items/`;
const fullPathOriginal = `${x.parentReference.path}/${x.name}`;
const matchFirstPrefixRes = fullPathOriginal.match(FIRST_COMMON_PREFIX_REGEX);
if (
matchFirstPrefixRes !== null &&
fullPathOriginal.startsWith(`${matchFirstPrefixRes[0]}${vaultName}`)
) {
const foundPrefix = `${matchFirstPrefixRes[0]}${vaultName}`;
key = fullPathOriginal.substring(foundPrefix.length + 1);
} else if (x.parentReference.path.startsWith(SECOND_COMMON_PREFIX_RAW)) {
// it's something like
// /drive/items/<some_id>!<another_id>:/${vaultName}/<subfolder>
// 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${constructFromDriveItemToRemoteItemError(
x
)}`
);
}
} else {
throw Error(
`we meet file/folder and do not know how to deal with it:\n${constructFromDriveItemToRemoteItemError(
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<any>;
constructor(
onedriveConfig: OnedriveConfig,
saveUpdatedConfigFunc: () => Promise<any>
) {
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<any>;
constructor(
onedriveConfig: OnedriveConfig,
vaultName: string,
saveUpdatedConfigFunc: () => Promise<any>
) {
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<any>
) => {
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();
let driveItems = res.value as DriveItem[];
while (NEXT_LINK_KEY in res) {
res = await client.client.api(res[NEXT_LINK_KEY]).get();
driveItems.push(...cloneDeep(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();
}
driveItems = driveItems.map((x) => {
const y = cloneDeep(x);
y.parentReference.path = y.parentReference.path.replace("/Apps", "/应用");
return y;
});
// 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<string> | 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<ArrayBuffer> => {
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 !== "<unknown display name>";
} 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 || "<unknown display name>";
};
/**
*
* 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";
};
+19 -24
View File
@@ -1,34 +1,29 @@
import { Buffer } from "buffer";
import { Readable } from "stream";
import { Vault } from "obsidian";
import { Upload } from "@aws-sdk/lib-storage";
import {
S3Client,
ListObjectsV2Command,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
HeadBucketCommand,
ListObjectsV2CommandInput,
ListObjectsV2CommandOutput,
HeadObjectCommandOutput,
} from "@aws-sdk/client-s3";
export { S3Client } from "@aws-sdk/client-s3";
import type { _Object } from "@aws-sdk/client-s3";
import {
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
HeadObjectCommandOutput,
ListObjectsV2Command,
ListObjectsV2CommandInput,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { Upload } from "@aws-sdk/lib-storage";
import { Buffer } from "buffer";
import * as mime from "mime-types";
import { Vault } from "obsidian";
import { Readable } from "stream";
import { RemoteItem, S3Config } from "./baseTypes";
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
import {
arrayBufferToBuffer,
bufferToArrayBuffer,
mkdirpInVault,
} from "./misc";
import * as mime from "mime-types";
import { RemoteItem, S3Config } from "./baseTypes";
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
export { S3Client } from "@aws-sdk/client-s3";
export const DEFAULT_S3_CONFIG = {
s3Endpoint: "",
+11 -18
View File
@@ -1,18 +1,11 @@
import { Buffer } from "buffer";
import { FileStats, Vault } from "obsidian";
import { Vault } from "obsidian";
import type { FileStat, WebDAVClient } from "webdav/web";
import { AuthType, BufferLike, createClient } from "webdav/web";
import type { WebDAVClient, ResponseDataDetailed, FileStat } from "webdav/web";
export type { WebDAVClient } from "webdav/web";
import type { RemoteItem, WebdavAuthType, WebdavConfig } from "./baseTypes";
import {
arrayBufferToBuffer,
bufferToArrayBuffer,
mkdirpInVault,
getPathFolder,
} from "./misc";
import type { RemoteItem, WebdavConfig } from "./baseTypes";
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
import { bufferToArrayBuffer, getPathFolder, mkdirpInVault } from "./misc";
export type { WebDAVClient } from "webdav/web";
export const DEFAULT_WEBDAV_CONFIG = {
address: "",
@@ -100,12 +93,12 @@ export class WrappedWebdavClient {
} else {
const res = await this.client.exists(`/${this.vaultName}`);
if (res) {
console.log("exits!");
// console.log("remote vault folder exits!");
this.vaultFolderExists = true;
} else {
console.log("not exists, creating");
console.log("remote vault folder not exists, creating");
await this.client.createDirectory(`/${this.vaultName}`);
console.log("created!");
console.log("remote vault folder created!");
this.vaultFolderExists = true;
}
}
@@ -125,7 +118,7 @@ export const getRemoteMeta = async (
) => {
await client.init();
const remotePath = getWebdavPath(fileOrFolderPath, client.vaultName);
console.log(`remotePath = ${remotePath}`);
// console.log(`remotePath = ${remotePath}`);
const res = (await client.client.stat(remotePath, {
details: false,
})) as FileStat;
@@ -165,7 +158,7 @@ export const uploadToRemote = async (
await client.client.putFileContents(uploadFile, "", {
overwrite: true,
onUploadProgress: (progress) => {
console.log(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
// console.log(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
},
});
@@ -297,8 +290,8 @@ export const deleteFromRemote = async (
};
export const checkConnectivity = async (client: WrappedWebdavClient) => {
await client.init();
try {
await client.init();
const results = await getRemoteMeta(client, "/");
if (results === undefined) {
return false;
+943
View File
@@ -0,0 +1,943 @@
import { App, Modal, Notice, PluginSettingTab, Setting } from "obsidian";
import type { SUPPORTED_SERVICES_TYPE, WebdavAuthType } from "./baseTypes";
import { exportSyncPlansToFiles } from "./debugMode";
import { exportQrCodeUri } from "./importExport";
import {
clearAllSyncMetaMapping,
clearAllSyncPlanRecords,
destroyDBs,
} from "./localdb";
import type RemotelySavePlugin from "./main"; // unavoidable
import { RemoteClient } from "./remote";
import {
DEFAULT_DROPBOX_CONFIG,
getAuthUrlAndVerifier as getAuthUrlAndVerifierDropbox,
sendAuthReq as sendAuthReqDropbox,
setConfigBySuccessfullAuthInplace,
} from "./remoteForDropbox";
import {
DEFAULT_ONEDRIVE_CONFIG,
getAuthUrlAndVerifier as getAuthUrlAndVerifierOnedrive,
} from "./remoteForOnedrive";
class PasswordModal extends Modal {
plugin: RemotelySavePlugin;
newPassword: string;
constructor(app: App, plugin: RemotelySavePlugin, newPassword: string) {
super(app);
this.plugin = plugin;
this.newPassword = newPassword;
}
onOpen() {
let { contentEl } = this;
// contentEl.setText("Add Or change password.");
contentEl.createEl("h2", { text: "Hold on and PLEASE READ ON..." });
contentEl.createEl("p", {
text: "If the field is not empty, files would be encrypted locally before being uploaded.",
});
contentEl.createEl("p", {
text: "If the field is empty, then files would be uploaded without encryption.",
});
contentEl.createEl("p", {
text: "Attention 1/5: The vault name is NOT encrypted. The plugin creates a folder with the vault name on some remote services.",
cls: "password-disclaimer",
});
contentEl.createEl("p", {
text: "Attention 2/5: The password itself is stored in PLAIN TEXT LOCALLY.",
cls: "password-disclaimer",
});
contentEl.createEl("p", {
text: "Attention 3/5: Some metadata are not encrypted or can be easily guessed. (File sizes are closed to their unencrypted ones, and directory path may be stored as 0-byte-size object.)",
cls: "password-disclaimer",
});
contentEl.createEl("p", {
text: "Attention 4/5: You should make sure the remote store IS EMPTY, or REMOTE FILES WERE ENCRYPTED BY THAT NEW PASSWORD, to avoid conflictions.",
});
contentEl.createEl("p", {
text: "Attention 5/5: The longer the password, the better.",
});
new Setting(contentEl)
.addButton((button) => {
button.setButtonText("The Second Confirm to change password.");
button.onClick(async () => {
this.plugin.settings.password = this.newPassword;
await this.plugin.saveSettings();
new Notice("New password saved!");
this.close();
});
button.setClass("password-second-confirm");
})
.addButton((button) => {
button.setButtonText("Go Back");
button.onClick(() => {
this.close();
});
});
}
onClose() {
let { contentEl } = this;
contentEl.empty();
}
}
class DropboxAuthModal 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 getAuthUrlAndVerifierDropbox(
this.plugin.settings.dropbox.clientID
);
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 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 OnedriveRevokeAuthModal extends Modal {
readonly plugin: RemotelySavePlugin;
readonly authDiv: HTMLDivElement;
readonly revokeAuthDiv: HTMLDivElement;
constructor(
app: App,
plugin: RemotelySavePlugin,
authDiv: HTMLDivElement,
revokeAuthDiv: HTMLDivElement
) {
super(app);
this.plugin = plugin;
this.authDiv = authDiv;
this.revokeAuthDiv = revokeAuthDiv;
}
async onOpen() {
let { contentEl } = this;
contentEl.createEl("p", {
text: 'Step 1: Go to the following address, click the "Edit" button for the plugin, then click "Remove these permissions" button on the page.',
});
const consentUrl = "https://microsoft.com/consent";
contentEl.createEl("p").createEl("a", {
href: consentUrl,
text: consentUrl,
});
contentEl.createEl("p", {
text: "Step 2: Click the button below, to clean the locally-saved login credentials.",
});
new Setting(contentEl)
.setName("Clean Locally-Saved Login Credentials")
.setDesc("You need to click the button.")
.addButton(async (button) => {
button.setButtonText("Clean");
button.onClick(async () => {
try {
this.plugin.settings.onedrive = JSON.parse(
JSON.stringify(DEFAULT_ONEDRIVE_CONFIG)
);
await this.plugin.saveSettings();
this.authDiv.toggleClass(
"onedrive-auth-button-hide",
this.plugin.settings.onedrive.username !== ""
);
this.revokeAuthDiv.toggleClass(
"onedrive-revoke-auth-button-hide",
this.plugin.settings.onedrive.username === ""
);
new Notice("Cleaned!");
this.close();
} catch (err) {
console.error(err);
new Notice("Something goes wrong while revoking");
}
});
});
}
onClose() {
let { contentEl } = this;
contentEl.empty();
}
}
class ExportSettingsQrCodeModal extends Modal {
plugin: RemotelySavePlugin;
constructor(app: App, plugin: RemotelySavePlugin) {
super(app);
this.plugin = plugin;
}
async onOpen() {
let { contentEl } = this;
const { rawUri, imgUri } = await exportQrCodeUri(
this.plugin.settings,
this.app.vault.getName(),
this.plugin.manifest.version
);
const div1 = contentEl.createDiv();
div1.createEl("p", {
text: "This exports not-oauth2 settings. (It means that Dropbox, OneDrive info are NOT exported.)",
});
div1.createEl("p", {
text: "You can use another device to scan this qrcode.",
});
div1.createEl("p", {
text: "Or, you can click the button to copy the special url.",
});
const div2 = contentEl.createDiv();
div2.createEl(
"button",
{
text: "Click to copy the special URI",
},
(el) => {
el.onclick = async () => {
await navigator.clipboard.writeText(rawUri);
new Notice("special uri copied to clipboard!");
};
}
);
const div3 = contentEl.createDiv();
div3.createEl(
"img",
{
cls: "qrcode-img",
},
async (el) => {
el.src = imgUri;
}
);
}
onClose() {
let { contentEl } = this;
contentEl.empty();
}
}
export class RemotelySaveSettingTab extends PluginSettingTab {
plugin: RemotelySavePlugin;
constructor(app: App, plugin: RemotelySavePlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
let { containerEl } = this;
containerEl.empty();
containerEl.createEl("h1", { text: "Remotely Save" });
//////////////////////////////////////////////////
// below for general
//////////////////////////////////////////////////
const generalDiv = containerEl.createEl("div");
generalDiv.createEl("h2", { text: "General" });
const passwordDiv = generalDiv.createEl("div");
let newPassword = `${this.plugin.settings.password}`;
new Setting(passwordDiv)
.setName("encryption password")
.setDesc(
'Password for E2E encryption. Empty for no password. You need to click "Confirm".'
)
.addText((text) =>
text
.setPlaceholder("")
.setValue(`${this.plugin.settings.password}`)
.onChange(async (value) => {
newPassword = value.trim();
})
)
.addButton(async (button) => {
button.setButtonText("Confirm");
button.onClick(async () => {
new PasswordModal(this.app, this.plugin, newPassword).open();
});
});
//////////////////////////////////////////////////
// below for general chooser (part 1/2)
//////////////////////////////////////////////////
// we need to create the div in advance of any other service divs
const serviceChooserDiv = generalDiv.createEl("div");
//////////////////////////////////////////////////
// below for s3
//////////////////////////////////////////////////
const s3Div = containerEl.createEl("div", { cls: "s3-hide" });
s3Div.toggleClass("s3-hide", this.plugin.settings.serviceType !== "s3");
s3Div.createEl("h2", { text: "Remote For S3 or compatible" });
s3Div.createEl("p", {
text: "Disclaimer: This plugin is NOT an official Amazon product.",
cls: "s3-disclaimer",
});
s3Div.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 bucket, please immediately delete the access key on your AWS (or other S3-service provider) settings.",
cls: "s3-disclaimer",
});
s3Div.createEl("p", {
text: "You need to configure CORS to allow requests from origin app://obsidian.md and capacitor://localhost and http://localhost",
});
s3Div.createEl("p", {
text: "Some Amazon S3 official docs for references:",
});
const s3LinksUl = s3Div.createEl("div").createEl("ul");
s3LinksUl.createEl("li").createEl("a", {
href: "https://docs.aws.amazon.com/general/latest/gr/s3.html",
text: "Endpoint and region info",
});
s3LinksUl.createEl("li").createEl("a", {
href: "https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html",
text: "Access key ID and Secret access key info",
});
s3LinksUl.createEl("li").createEl("a", {
href: "https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html",
text: "Configuring CORS",
});
new Setting(s3Div)
.setName("s3Endpoint")
.setDesc("s3Endpoint")
.addText((text) =>
text
.setPlaceholder("")
.setValue(this.plugin.settings.s3.s3Endpoint)
.onChange(async (value) => {
this.plugin.settings.s3.s3Endpoint = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(s3Div)
.setName("s3Region")
.setDesc("s3Region")
.addText((text) =>
text
.setPlaceholder("")
.setValue(`${this.plugin.settings.s3.s3Region}`)
.onChange(async (value) => {
this.plugin.settings.s3.s3Region = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(s3Div)
.setName("s3AccessKeyID")
.setDesc("s3AccessKeyID")
.addText((text) =>
text
.setPlaceholder("")
.setValue(`${this.plugin.settings.s3.s3AccessKeyID}`)
.onChange(async (value) => {
this.plugin.settings.s3.s3AccessKeyID = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(s3Div)
.setName("s3SecretAccessKey")
.setDesc("s3SecretAccessKey")
.addText((text) =>
text
.setPlaceholder("")
.setValue(`${this.plugin.settings.s3.s3SecretAccessKey}`)
.onChange(async (value) => {
this.plugin.settings.s3.s3SecretAccessKey = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(s3Div)
.setName("s3BucketName")
.setDesc("s3BucketName")
.addText((text) =>
text
.setPlaceholder("")
.setValue(`${this.plugin.settings.s3.s3BucketName}`)
.onChange(async (value) => {
this.plugin.settings.s3.s3BucketName = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(s3Div)
.setName("check connectivity")
.setDesc("check connectivity")
.addButton(async (button) => {
button.setButtonText("Check");
button.onClick(async () => {
new Notice("Checking...");
const client = new RemoteClient("s3", this.plugin.settings.s3);
const res = await client.checkConnectivity();
if (res) {
new Notice("Great! The bucket can be accessed.");
} else {
new Notice("The S3 bucket cannot be reached.");
}
});
});
//////////////////////////////////////////////////
// below for dropbpx
//////////////////////////////////////////////////
const dropboxDiv = containerEl.createEl("div", { cls: "dropbox-hide" });
dropboxDiv.toggleClass(
"dropbox-hide",
this.plugin.settings.serviceType !== "dropbox"
);
dropboxDiv.createEl("h2", { text: "Remote For Dropbox" });
dropboxDiv.createEl("p", {
text: "Disclaimer: This app is NOT an official Dropbox product.",
cls: "dropbox-disclaimer",
});
dropboxDiv.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 Dropbox, please immediately disconnect this app on https://www.dropbox.com/account/connected_apps .",
cls: "dropbox-disclaimer",
});
dropboxDiv.createEl("p", {
text: `We will create and sync inside the folder /Apps/${
this.plugin.manifest.id
}/${this.app.vault.getName()} on your Dropbox.`,
});
const dropboxSelectAuthDiv = dropboxDiv.createDiv();
const dropboxAuthDiv = dropboxSelectAuthDiv.createDiv({
cls: "dropbox-auth-button-hide",
});
const dropboxRevokeAuthDiv = dropboxSelectAuthDiv.createDiv({
cls: "dropbox-revoke-auth-button-hide",
});
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`
)
.addButton(async (button) => {
button.setButtonText("Revoke Auth");
button.onClick(async () => {
try {
const self = this;
const client = new RemoteClient(
"dropbox",
undefined,
undefined,
this.plugin.settings.dropbox,
undefined,
this.app.vault.getName(),
() => self.plugin.saveSettings()
);
await client.revokeAuth();
this.plugin.settings.dropbox = JSON.parse(
JSON.stringify(DEFAULT_DROPBOX_CONFIG)
);
await this.plugin.saveSettings();
dropboxAuthDiv.toggleClass(
"dropbox-auth-button-hide",
this.plugin.settings.dropbox.username !== ""
);
dropboxRevokeAuthDiv.toggleClass(
"dropbox-revoke-auth-button-hide",
this.plugin.settings.dropbox.username === ""
);
new Notice("Revoked!");
} catch (err) {
console.error(err);
new Notice("Something goes wrong while revoking");
}
});
});
new Setting(dropboxAuthDiv)
.setName("Auth")
.setDesc("Auth")
.addButton(async (button) => {
button.setButtonText("Auth");
button.onClick(async () => {
const modal = new DropboxAuthModal(
this.app,
this.plugin,
dropboxAuthDiv,
dropboxRevokeAuthDiv,
dropboxRevokeAuthSetting
);
this.plugin.oauth2Info.helperModal = modal;
this.plugin.oauth2Info.authDiv = dropboxAuthDiv;
this.plugin.oauth2Info.revokeDiv = dropboxRevokeAuthDiv;
this.plugin.oauth2Info.revokeAuthSetting = dropboxRevokeAuthSetting;
modal.open();
});
});
dropboxAuthDiv.toggleClass(
"dropbox-auth-button-hide",
this.plugin.settings.dropbox.username !== ""
);
dropboxRevokeAuthDiv.toggleClass(
"dropbox-revoke-auth-button-hide",
this.plugin.settings.dropbox.username === ""
);
new Setting(dropboxDiv)
.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(
"dropbox",
undefined,
undefined,
this.plugin.settings.dropbox,
undefined,
this.app.vault.getName(),
() => self.plugin.saveSettings()
);
const res = await client.checkConnectivity();
if (res) {
new Notice("Great! We can connect to Dropbox!");
} else {
new Notice("We cannot connect to Dropbox.");
}
});
});
//////////////////////////////////////////////////
// below for onedrive
//////////////////////////////////////////////////
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 Microsoft / 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 () => {
new OnedriveRevokeAuthModal(
this.app,
this.plugin,
onedriveAuthDiv,
onedriveRevokeAuthDiv
).open();
});
});
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.");
}
});
});
//////////////////////////////////////////////////
// below for webdav
//////////////////////////////////////////////////
const webdavDiv = containerEl.createEl("div", { cls: "webdav-hide" });
webdavDiv.toggleClass(
"webdav-hide",
this.plugin.settings.serviceType !== "webdav"
);
webdavDiv.createEl("h2", { text: "Remote For Webdav" });
webdavDiv.createEl("p", {
text: "Disclaimer: The information is stored in PLAIN TEXT locally. Other malicious/harmful/faulty plugins may read the info. If you see any unintentional access to your webdav server, please immediately change the username and password.",
cls: "webdav-disclaimer",
});
webdavDiv.createEl("p", {
text: "You need to configure CORS to allow requests from origin app://obsidian.md and capacitor://localhost and http://localhost",
});
webdavDiv.createEl("p", {
text: `We will create and sync inside the folder /${this.app.vault.getName()} on your server.`,
});
new Setting(webdavDiv)
.setName("server address")
.setDesc("server address")
.addText((text) =>
text
.setPlaceholder("")
.setValue(this.plugin.settings.webdav.address)
.onChange(async (value) => {
this.plugin.settings.webdav.address = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(webdavDiv)
.setName("server username")
.setDesc("server username")
.addText((text) =>
text
.setPlaceholder("")
.setValue(this.plugin.settings.webdav.username)
.onChange(async (value) => {
this.plugin.settings.webdav.username = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(webdavDiv)
.setName("server password")
.setDesc("server password")
.addText((text) =>
text
.setPlaceholder("")
.setValue(this.plugin.settings.webdav.password)
.onChange(async (value) => {
this.plugin.settings.webdav.password = value.trim();
await this.plugin.saveSettings();
})
);
new Setting(webdavDiv)
.setName("server auth type")
.setDesc("If no password, this option would be ignored.")
.addDropdown((dropdown) => {
dropdown.addOption("basic", "basic");
// dropdown.addOption("digest", "digest");
dropdown
.setValue(this.plugin.settings.webdav.authType)
.onChange(async (val: WebdavAuthType) => {
this.plugin.settings.webdav.authType = val;
await this.plugin.saveSettings();
});
});
new Setting(webdavDiv)
.setName("check connectivity")
.setDesc("check connectivity")
.addButton(async (button) => {
button.setButtonText("Check");
button.onClick(async () => {
new Notice("Checking...");
const client = new RemoteClient(
"webdav",
undefined,
this.plugin.settings.webdav,
undefined,
undefined,
this.app.vault.getName()
);
const res = await client.checkConnectivity();
if (res) {
new Notice("Great! The webdav server can be accessed.");
} else {
new Notice(
"The webdav server cannot be reached (possible to be any of address/username/password/authtype/CORS errors)."
);
}
});
});
//////////////////////////////////////////////////
// below for general chooser (part 2/2)
//////////////////////////////////////////////////
// we need to create chooser
// after all service-div-s being created
new Setting(serviceChooserDiv)
.setName("Choose service")
.setDesc("Choose a service.")
.addDropdown(async (dropdown) => {
dropdown.addOption("s3", "S3 or compatible");
dropdown.addOption("dropbox", "Dropbox");
dropdown.addOption("webdav", "Webdav (beta)");
dropdown.addOption("onedrive", "OneDrive (alpha)");
dropdown
.setValue(this.plugin.settings.serviceType)
.onChange(async (val: SUPPORTED_SERVICES_TYPE) => {
this.plugin.settings.serviceType = val;
s3Div.toggleClass(
"s3-hide",
this.plugin.settings.serviceType !== "s3"
);
dropboxDiv.toggleClass(
"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"
);
await this.plugin.saveSettings();
});
});
//////////////////////////////////////////////////
// below for import and export functions
//////////////////////////////////////////////////
// import and export
const importExportDiv = containerEl.createEl("div");
importExportDiv.createEl("h2", {
text: "Import and Export Partial Settings",
});
new Setting(importExportDiv)
.setName("export")
.setDesc("Export not-oauth2 settings by generating a qrcode.")
.addButton(async (button) => {
button.setButtonText("Get QR Code");
button.onClick(async () => {
new ExportSettingsQrCodeModal(this.app, this.plugin).open();
});
});
new Setting(importExportDiv)
.setName("import")
.setDesc(
"You should open a camera or scan-qrcode app, to manually scan the QR code."
);
//////////////////////////////////////////////////
// below for debug
//////////////////////////////////////////////////
const debugDiv = containerEl.createEl("div");
debugDiv.createEl("h2", { text: "Debug" });
const syncPlanDiv = debugDiv.createEl("div");
new Setting(syncPlanDiv)
.setName("export sync plans")
.setDesc(
"Sync plans are created every time after you trigger sync and before the actual sync. Useful to know what would actually happen in those sync. Click the button to export sync plans"
)
.addButton(async (button) => {
button.setButtonText("Export");
button.onClick(async () => {
await exportSyncPlansToFiles(this.plugin.db, this.app.vault);
new Notice("sync plans history exported");
});
});
new Setting(syncPlanDiv)
.setName("delete sync plans history in db")
.setDesc("delete sync plans history in db")
.addButton(async (button) => {
button.setButtonText("Delete History");
button.onClick(async () => {
await clearAllSyncPlanRecords(this.plugin.db);
new Notice("sync plans history (in db) deleted");
});
});
const syncMappingDiv = debugDiv.createEl("div");
new Setting(syncMappingDiv)
.setName("delete sync mappings history in db")
.setDesc(
"Sync mappings history stores the actual LOCAL last modified time of the REMOTE objects. Clearing it may cause unnecessary data exchanges in next-time sync. Click the button to delete sync mappings history in db"
)
.addButton(async (button) => {
button.setButtonText("Delete Sync Mappings");
button.onClick(async () => {
await clearAllSyncMetaMapping(this.plugin.db);
new Notice("sync mappings history (in local db) deleted");
});
});
const dbsResetDiv = debugDiv.createEl("div");
new Setting(dbsResetDiv)
.setName("reset local internal cache/databases")
.setDesc(
"Reset local internal caches/databases (for debugging purposes). You would want to reload the plugin after resetting this. This option will not empty the {s3, password...} settings."
)
.addButton(async (button) => {
button.setButtonText("Reset");
button.onClick(async () => {
await destroyDBs();
new Notice(
"Local internal cache/databases deleted. Please manually reload the plugin."
);
});
});
}
}
+59 -16
View File
@@ -1,20 +1,20 @@
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
import {
clearDeleteRenameHistoryOfKey,
upsertSyncMetaMappingData,
getSyncMetaMappingByRemoteKey,
} from "./localdb";
import type { FileFolderHistoryRecord, InternalDBs } from "./localdb";
import { RemoteClient } from "./remote";
import type { SUPPORTED_SERVICES_TYPE, RemoteItem } from "./baseTypes";
import { mkdirpInVault, isHiddenPath, isVaildText, setToString } from "./misc";
import { TAbstractFile, TFile, TFolder, Vault } from "obsidian";
import type { RemoteItem, SUPPORTED_SERVICES_TYPE } from "./baseTypes";
import {
decryptBase32ToString,
encryptStringToBase32,
decryptBase64urlToString,
encryptStringToBase64url,
MAGIC_ENCRYPTED_PREFIX_BASE32,
MAGIC_ENCRYPTED_PREFIX_BASE64URL,
} from "./encrypt";
import type { FileFolderHistoryRecord, InternalDBs } from "./localdb";
import {
clearDeleteRenameHistoryOfKey,
getSyncMetaMappingByRemoteKey,
upsertSyncMetaMappingData,
} from "./localdb";
import { isHiddenPath, isVaildText, mkdirpInVault } from "./misc";
import { RemoteClient } from "./remote";
export type SyncStatusType =
| "idle"
@@ -85,7 +85,7 @@ export const isPasswordOk = async (
}
const santyCheckKey = remote[0].key;
if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) {
// this is encrypted!
// this is encrypted using old base32!
// try to decrypt it using the provided password.
if (password === "") {
return {
@@ -96,6 +96,38 @@ export const isPasswordOk = async (
try {
const res = await decryptBase32ToString(santyCheckKey, password);
// additional test
// because iOS Safari bypasses decryption with wrong password!
if (isVaildText(res)) {
return {
ok: true,
reason: "password_matched",
} as PasswordCheckType;
} else {
return {
ok: false,
reason: "invalid_text_after_decryption",
} as PasswordCheckType;
}
} catch (error) {
return {
ok: false,
reason: "password_not_matched",
} as PasswordCheckType;
}
}
if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE64URL)) {
// this is encrypted using new base64url!
// try to decrypt it using the provided password.
if (password === "") {
return {
ok: false,
reason: "remote_encrypted_local_no_password",
} as PasswordCheckType;
}
try {
const res = await decryptBase64urlToString(santyCheckKey, password);
// additional test
// because iOS Safari bypasses decryption with wrong password!
if (isVaildText(res)) {
@@ -145,7 +177,15 @@ const ensembleMixedStates = async (
const remoteEncryptedKey = entry.key;
let key = remoteEncryptedKey;
if (password !== "") {
key = await decryptBase32ToString(remoteEncryptedKey, password);
if (remoteEncryptedKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) {
key = await decryptBase32ToString(remoteEncryptedKey, password);
} else if (
remoteEncryptedKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE64URL)
) {
key = await decryptBase64urlToString(remoteEncryptedKey, password);
} else {
throw Error(`unexpected key=${remoteEncryptedKey}`);
}
}
const backwardMapping = await getSyncMetaMappingByRemoteKey(
remoteType,
@@ -435,7 +475,10 @@ const dispatchOperationToActual = async (
if (password !== "") {
remoteEncryptedKey = state.remote_encrypted_key;
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
remoteEncryptedKey = await encryptStringToBase32(key, password);
// the old version uses base32
// remoteEncryptedKey = await encryptStringToBase32(key, password);
// the new version users base64url
remoteEncryptedKey = await encryptStringToBase64url(key, password);
}
}
+15
View File
@@ -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;
}
+7 -6
View File
@@ -1,14 +1,15 @@
import * as fs from "fs";
import * as path from "path";
import * as chai from "chai";
import chaiAsPromised from "chai-as-promised";
import { base64ToBase32, bufferToArrayBuffer } from "../src/misc";
import * as fs from "fs";
import * as path from "path";
import {
decryptArrayBuffer,
decryptBase32ToString,
encryptArrayBuffer,
encryptStringToBase32,
encryptStringToBase64url,
} from "../src/encrypt";
import { base64ToBase64url, bufferToArrayBuffer } from "../src/misc";
chai.use(chaiAsPromised);
const expect = chai.expect;
@@ -52,7 +53,7 @@ describe("Encryption tests", () => {
).toString("utf-8");
const password = "somepassword";
const saltHex = "8302F586FAB491EC";
const enc = await encryptStringToBase32(
const enc = await encryptStringToBase64url(
fileContent,
password,
undefined,
@@ -65,9 +66,9 @@ describe("Encryption tests", () => {
const opensslBase64Res =
"U2FsdGVkX1+DAvWG+rSR7BPXMnlvSSVGMdjsx7kE1CTH+28P+yAZRdDGgFWMGkMd";
// we output base32, so we need some transformation
const opensslBase32Res = base64ToBase32(opensslBase64Res);
const opensslBase64urlRes = base64ToBase64url(opensslBase64Res);
expect(enc).equal(opensslBase32Res);
expect(enc).equal(opensslBase64urlRes);
});
it("should encrypt binary file and get the same result as openssl", async () => {
-3
View File
@@ -1,8 +1,5 @@
import * as fs from "fs";
import * as path from "path";
import { expect } from "chai";
import { JSDOM } from "jsdom";
import * as misc from "../src/misc";
describe("Misc: hidden file", () => {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"0.2.4": "0.12.15"
"0.2.7": "0.12.15"
}
+6 -2
View File
@@ -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"),