Compare commits

...
152 Commits
Author SHA1 Message Date
fyears f7082616aa 0.2.13 2022-01-22 22:20:11 +08:00
fyears d9f926e376 add command 2022-01-22 22:19:31 +08:00
fyears 13a2d6f53a 0.2.12 2022-01-22 18:02:15 +08:00
fyears 5092c59ed3 skip empty file for onedrive 2022-01-22 18:01:21 +08:00
fyears fc9ab2e63f add support for Livefolders 2022-01-22 17:39:45 +08:00
fyears 91af2c849f revert main.ts after debugging 2022-01-22 17:17:53 +08:00
fyears fafe74526d Merge branch 'master' of https://github.com/fyears/remotely-save 2022-01-22 17:13:15 +08:00
fyears 320f91f1a5 add webdav depth=1 2022-01-22 17:11:12 +08:00
fyears ea7c9ae203 remove manual sync limit in readme 2022-01-20 13:11:30 +08:00
fyears 2bebc7226d bump to 0.2.11 2022-01-16 23:16:12 +08:00
fyears c9b86fc42d polish readme 2022-01-16 23:15:18 +08:00
fyears 5d11801c28 split qrcode readme 2022-01-16 23:10:59 +08:00
fyears 1f8754633f add readme for auto sync 2022-01-16 23:07:26 +08:00
fyears 6cdbc5b32e auto sync 2022-01-16 23:02:49 +08:00
fyears a78a2a88cf use feather icon 2022-01-16 13:38:20 +08:00
fyears 4035218706 ignore data.json 2022-01-16 13:38:00 +08:00
fyears b0cb1248a5 remove vaultRandomID in qr 2022-01-09 01:06:50 +08:00
fyears f936704a6a merge the branch of adding vault name into dbs 2022-01-08 18:42:48 +08:00
fyears 04f3a0c97c add vault name into dbs 2022-01-08 18:41:11 +08:00
fyears c637038d95 show progress 2022-01-08 14:45:36 +08:00
fyears e7c24b55a3 find a compilable esbuild version 2022-01-08 14:43:53 +08:00
fyears 9a66e3181d use webpack again?? 2022-01-08 14:36:22 +08:00
fyears 510755d7f0 add link 2022-01-08 13:47:09 +08:00
fyears 27fe428fe4 example .env 2022-01-08 01:51:28 +08:00
fyears ffabaacaf1 use github secret 2022-01-08 01:50:19 +08:00
fyears 6790bb01d3 jump to 0.2.10 2022-01-06 18:38:57 +08:00
fyears 131f3f70e1 fix format 2022-01-06 18:38:35 +08:00
fyears b551e0c1b5 fix config 2022-01-06 18:38:18 +08:00
fyears c65c0aa258 finally add total downloads 2022-01-06 01:48:18 +08:00
fyears 1f0030b514 add stats for 0.2.9 2022-01-05 10:08:36 +08:00
fyears 07896f92a3 0.2.9 2022-01-05 10:05:24 +08:00
fyears 9eddfb02d6 encode config 2022-01-05 10:04:58 +08:00
fyears f0dd3bb5ef add button to copy url of dropbox 2022-01-05 09:30:07 +08:00
fyears b91c0e694c 0.2.8 2022-01-05 00:59:59 +08:00
fyears 84b03d2c2b add button to copy url 2022-01-05 00:58:59 +08:00
fyears 313eff6a14 log debug every sync file 2022-01-05 00:24:21 +08:00
fyears 8671ba4660 use loglevel everywhere 2022-01-05 00:23:49 +08:00
fyears 3b195f33c8 change some desc 2022-01-04 23:25:45 +08:00
fyears 65e18aa4b7 special folder for logo 2022-01-03 23:39:09 +08:00
fyears 2cedef8168 add diff sizes' logos 2022-01-03 23:37:43 +08:00
fyears 22bc74f4d7 statement that only support onedrive for personal currently 2022-01-03 20:35:19 +08:00
fyears 277793ccf4 download stats for 0.2.7 2022-01-03 01:07:41 +08:00
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
fyears 2576354269 clean desc 2021-12-12 00:56:40 +08:00
fyears 122fec1d75 no need for code mirror here 2021-12-12 00:04:00 +08:00
fyears 27477f8d97 fix typo 2021-12-12 00:02:41 +08:00
fyears e9890c3c79 0.2.4 2021-12-12 00:01:27 +08:00
fyears 44480be707 webdav also sync in vault folder 2021-12-11 23:55:12 +08:00
fyears 437e041e71 fix var in dropbox 2021-12-11 23:52:32 +08:00
fyears 9e4fed4504 tell users vault name is not encrypted 2021-12-11 23:13:55 +08:00
fyears 70e2884f89 scan qr code readme 2021-12-11 23:10:30 +08:00
fyears fb63a38fa0 add draft logo 2021-12-11 22:47:44 +08:00
fyears d21d90868a lfs track more imgs 2021-12-11 22:47:16 +08:00
fyears e6591721e2 prepare to import svg 2021-12-11 22:45:21 +08:00
fyears b6bae792c6 0.2.3 2021-12-11 17:34:45 +08:00
fyears 8624141780 add qrcode export import 2021-12-11 17:33:55 +08:00
fyears 563c2c3ff6 readme 2021-12-10 01:34:19 +08:00
fyears 9a72ac8d66 0.2.2 2021-12-10 01:14:12 +08:00
fyears d980bcbc1b rename titles 2021-12-10 01:12:31 +08:00
fyears 6ebe1a8966 no support digest webdav now 2021-12-10 01:10:38 +08:00
fyears d0ab1d8abe remove webdav hide 2021-12-10 01:09:51 +08:00
fyears b86d116830 add desc for webdav readme 2021-12-10 01:05:47 +08:00
fyears 7d5eaad36f remove unnecessary log 2021-12-10 00:40:54 +08:00
fyears 7a13f4566c full word 2021-12-10 00:18:26 +08:00
fyears d355f72b7a update to 0.2.1 2021-12-10 00:17:31 +08:00
fyears 9b1eebdf90 standarized codes 2021-12-10 00:16:28 +08:00
fyears 2ce461ed6a allow multi pages in dropbox 2021-12-10 00:11:53 +08:00
fyears 3aa91a2a1e add env to build 2021-12-04 01:46:40 +08:00
fyears 2a3e0524c5 fix terrible typo 2021-12-04 01:21:37 +08:00
fyears 2f20ca61ce rebranding 2021-12-04 01:21:15 +08:00
fyears 68ec8b5454 bump version and update name 2021-12-04 01:18:20 +08:00
fyears 2bbbd569c5 fix typo 2021-12-04 01:11:17 +08:00
fyears 62936c4473 attempt to reduce size and use native crypto 2021-12-04 00:55:31 +08:00
fyears 789d07261e 0.1.10 2021-12-01 10:11:20 +08:00
fyears ccd3b49b4b inject env var in webpack 2021-12-01 10:10:39 +08:00
fyears 2930fe5350 0.1.9 2021-12-01 01:50:00 +08:00
fyears a9abf6d834 webpack is slow but works 2021-12-01 01:49:29 +08:00
fyears bdf09e24c3 remove assert 2021-12-01 01:49:07 +08:00
fyears 8dc4b835c9 0.1.8 2021-12-01 00:34:02 +08:00
fyears 2e800af4f8 dropbox should use sub folder 2021-12-01 00:33:24 +08:00
fyears e9e9e2b6eb fix typo 2021-11-30 00:44:26 +08:00
fyears 274e0b8b28 bump to 0.1.7 2021-11-30 00:42:05 +08:00
fyears 2147332cf4 more about dropbox 2021-11-30 00:41:41 +08:00
fyears 262bb9a1fe more disclaimer 2021-11-30 00:32:43 +08:00
fyears 2669a3e8ad fix auth dropbox 2021-11-30 00:21:15 +08:00
fyears bdac16ee24 define clientID or AppKey by env var 2021-11-29 01:03:17 +08:00
fyears 0a7de6588a ugly way to refresh token 2021-11-29 00:04:24 +08:00
fyears 1effcdd7f7 basic dropbox auth flow 2021-11-28 16:28:52 +08:00
fyears 00c840a758 basically working dropbox 2021-11-28 12:20:38 +08:00
fyears 2eb1545f9d more misc 2021-11-28 12:18:44 +08:00
fyears 0d75ea92b1 polish get parent folder 2021-11-28 10:45:46 +08:00
fyears d6cbf4260a esbuild is way faster 2021-11-27 14:49:07 +08:00
fyears 9389039c7a avoid using for await 2021-11-27 14:43:19 +08:00
fyears aa3aecb074 0.1.6 2021-11-27 00:51:01 +08:00
fyears 9128f9ed5b add support for ios 5-times click 2021-11-27 00:49:21 +08:00
fyears b581ef3f31 this is 0.1.5 correctly 2021-11-22 00:12:21 +08:00
fyears d8c42adc55 update readme 2021-11-22 00:06:50 +08:00
fyears 45b18ca63d 0.1.5 2021-11-21 23:55:43 +08:00
fyears 52e9d3298a hide webdav yet 2021-11-21 23:54:50 +08:00
fyears e82dfc1e88 fix unknown decision 2021-11-21 22:54:31 +08:00
fyears 0be19b677b selective menu for s3 and webdav 2021-11-21 16:46:01 +08:00
fyears d1839706af basically workable webdav 2021-11-21 15:33:14 +08:00
fyears ce0cc232c8 init methods for webdav 2021-11-20 23:51:20 +08:00
fyears 12d09a30ef 0.1.3 2021-11-15 23:37:33 +08:00
fyears 3fdc443ecf more cors 2021-11-15 23:36:58 +08:00
fyears 0700af855d ignore more 2021-11-15 23:36:44 +08:00
fyears d3b81ca3dc 0.1.2 2021-11-15 10:06:40 +08:00
fyears 2a572fb1bd remove unusused cm 2021-11-15 10:06:10 +08:00
fyears 76f90ea375 normalize id 2021-11-15 09:56:41 +08:00
fyears d753617628 go to 0.1.0 2021-11-15 09:52:54 +08:00
fyears 69c9686ce8 bump 0.0.17 2021-11-15 01:03:57 +08:00
fyears b3e3022751 correct sources 2021-11-15 01:03:25 +08:00
fyears 2ad342c201 many docs 2021-11-15 00:35:56 +08:00
fyears 2182c6e886 rename sync algo doc 2021-11-15 00:30:30 +08:00
fyears 0d9cbd176c bump ver 2021-11-15 00:27:31 +08:00
fyears d944260a97 update enc iter 2021-11-15 00:26:40 +08:00
fyears 14f305adda rename project 2021-11-14 22:37:43 +08:00
fyears 6459dcd228 tutorial of using s3 2021-11-14 22:01:07 +08:00
fyears 714807cdc7 fix endpoint 2021-11-14 21:12:31 +08:00
fyears 59645e620a manually delete indexeddb 2021-11-14 20:51:29 +08:00
fyears 125da9d734 more debug 2021-11-14 20:47:47 +08:00
fyears 808ae709fb 0.0.15 2021-11-14 20:25:20 +08:00
fyears 16c3c11fd8 use localforage 2021-11-14 20:24:33 +08:00
47 changed files with 5226 additions and 835 deletions
+3
View File
@@ -0,0 +1,3 @@
DROPBOX_APP_KEY=
ONEDRIVE_CLIENT_ID=
ONEDRIVE_AUTHORITY=https://
+2
View File
@@ -3,3 +3,5 @@
*.jpeg filter=lfs diff=lfs merge=lfs -text
*.pdf filter=lfs diff=lfs merge=lfs -text
*.enc filter=lfs diff=lfs merge=lfs -text
*.svg filter=lfs diff=lfs merge=lfs -text
*.excalidraw filter=lfs diff=lfs merge=lfs -text
+7
View File
@@ -13,6 +13,13 @@ jobs:
build:
runs-on: ubuntu-latest
environment: env-for-buildci
env:
DROPBOX_APP_KEY: ${{secrets.DROPBOX_APP_KEY}}
ONEDRIVE_CLIENT_ID: ${{secrets.ONEDRIVE_CLIENT_ID}}
ONEDRIVE_AUTHORITY: ${{secrets.ONEDRIVE_AUTHORITY}}
strategy:
matrix:
node-version: [16.x]
+20 -17
View File
@@ -1,17 +1,20 @@
# Intellij
*.iml
.idea
# npm
node_modules
package-lock.json
# build
main.js
*.js.map
# obsidian
data.json
# hidden files
.*
# Intellij
*.iml
.idea
# npm
node_modules
package-lock.json
# build
main.js
*.js.map
# obsidian
data.json
# debug
logs.txt
# hidden files
.*
+1
View File
@@ -1,2 +1,3 @@
node_modules/
main.js
data.json
+89 -13
View File
@@ -1,26 +1,102 @@
# Save Remote
# Remotely Save
This is yet another 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.
## Download and Install
[![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)
[![BuildCI](https://github.com/fyears/obsidian-save-remote/actions/workflows/auto-build.yml/badge.svg)](https://github.com/fyears/obsidian-save-remote/actions/workflows/auto-build.yml)
Every artifacts are placed in the "Summary" under every successful builds.
Besides manually download the files, you can also use [Obsidian42 - BRAT](https://github.com/TfTHacker/obsidian42-brat) to install this plugin.
[![total downloads auto count)](https://remotely-save.github.io/auto-download-stats/totalDownloads.svg)](https://github.com/fyears/remotely-save/releases)
## Disclaimer
**This is NOT the official sync service provided by Obsidian.**
- **This is NOT the [official sync service](https://obsidian.md/sync) provided by Obsidian.**
## !!!Caution!!!
As of October 2021, the plugin is under development. **DO NOT USE IT for any serious vaults.** Prepare for data loss!
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 for personal
- 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.
- **Scheduled auto sync supported.** You can also manually trigger the sync using sidebar ribbon, or using the command from the command palette (or even bind the hot key combination to the command then press the hot key combination).
- **[Minimal Intrusive](./docs/minimal_intrusive_design.md).**
- **Fully open source under [Apache-2.0 License](./LICENSE).**
- **[Sync Algorithm open](./docs/sync_algorithm.md) for discussion.**
## Limitations
- **"deletion" operation can only be triggered from local device.** It's because of the "[minimal intrusive design](./docs/minimal_intrusive_design.md)". May be changed in the future.
- **No Conflict resolution. No content-diff-and-patch algorithm.** All files and folders are compared using their local and remote "last modified time" and those with later "last modified time" wins.
- **Cloud services cost you money.** Always be aware of the costs and pricing.
- **All files or folder starting with `.` (dot) or `_` (underscore) are treated as hidden files, and would NOT be synced.** It's useful if you have some files just staying locally. But this strategy also means that themes / other plugins / settings of this plugin would neither be synced.
## Questions, Suggestions, Or Bugs
You are greatly welcome to ask questions, post any suggestions, or report any bugs! The project is mainly maintained on GitHub:
- Questions: [GitHub repo Discussions](https://github.com/fyears/remotely-save/discussions)
- Suggestions: also in [GitHub repo Discussions](https://github.com/fyears/remotely-save/discussions)
- Bugs: [GitHub repo Issues](https://github.com/fyears/remotely-save/issues) (NOT Discussion)
Additionally, the plugin author may occasionally visit Obsidian official forum and official Discord server, and pay attention to this-plugin-related information there.
## Download and Install
- Option #1: Search in the official "community plugin list", or click [obsidian://show-plugin?id=remotely-save](obsidian://show-plugin?id=remotely-save), then install the 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
Remote service in support list:
### S3
- [x] AWS S3
- [ ] webdav
- Prepare your S3 (-compatible) service information: [endpoint, region](https://docs.aws.amazon.com/general/latest/gr/s3.html), [access key id, secret access key](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html), bucket name. The bucket should be empty and solely for syncing a vault.
- Configure (enable) [CORS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html) for requests from `app://obsidian.md` and `capacitor://localhost` and `http://localhost`. It's unfortunately required, because the plugin sends requests from a browser-like envirement. And those addresses are tested and found on desktop and ios and android.
- Download and enable this plugin.
- Enter your information to the settings of this plugin.
- If you want to enable end-to-end encryption, also set a password in settings. If you do not specify a password, the files and folders are synced in plain, original content to the cloud.
- Click the new "circle arrow" icon on the ribbon (the left sidebar), **every time** you want to sync your vault between local and remote. (Or, you could configure auto sync in the settings panel (See next chapter).) While syncing, the icon becomes "two half-circle arrows". Besides clicking the icon on the sidebar ribbon, you can also activate the corresponding command in the command palette.
- **Be patient while syncing.** Especially in the first-time sync.
### Dropbox
- **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` 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 for personal
- **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).
- This plugin only works for "OneDrive for personal", and not works for "OneDrive for Business" (yet). See [#11](https://github.com/fyears/remotely-save/issues/11) to further details.
- 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, 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**.
## Scheduled Auto Sync
- You can configure auto syncing every N minutes in settings.
- In auto sync mode, if any error occurs, the plugin would **fail silently**.
- Auto sync only works when Obsidian is being opened. It's **technically impossible** to auto sync while Obsidian is in background, because the plugin just works in the browser environment provided by Obsidian.
## Bonus: Import And Export Not-Oauth2 Plugin Settings By QR Code
See [here](./docs/import_export_some_settings.md) for more details.
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a9bf1cc2403c3a32e7a6f5d5829bb065573922dc050ae2fdb8b3d34b4c87ecf5
size 8296
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6990a598379c457442345e50c3f056c4598a121523bc10980835c08fc7447a54
size 10119
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:432198e63fa9676541a41436917e025d1ea6af3772139c1dea450fcc0155cfd0
size 13054
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fe3b5b8bf9c32e57c0af366c455b11ae68eb9fb8a32f371c9c445b3e7e89cc02
size 1120
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f68df7bae94eb4c4cddfa6af47d7819d01a6482d4f6afe6ef3236f72f9adf15f
size 1523
+1
View File
@@ -0,0 +1 @@
This work is licensed under CC BY-SA 4.0. To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/
+75
View File
@@ -0,0 +1,75 @@
# the logo
Some different sizes' logos are needed.
## license
<a rel="license" href="http://creativecommons.org/licenses/by-sa/4.0/"><img alt="Creative Commons License" style="border-width:0" src="https://i.creativecommons.org/l/by-sa/4.0/88x31.png" /></a><br />This work is licensed under a <a rel="license" href="http://creativecommons.org/licenses/by-sa/4.0/">Creative Commons Attribution-ShareAlike 4.0 International License</a>.
## the process of creation
No professional designers are here. Thus the following steps involve many programer styles. :-)
1. use excalidraw and export png and svg.
```bash
# results
logo.excalidraw
logo.png
logo.svg
```
2. manually edit the `logo.svg` and make background transparent.
```bash
# results
logo-transparent.svg
```
3. use python library [`svgutils`](https://github.com/btel/svg_utils) to make a strictly square figure. The [doc](https://svgutils.readthedocs.io/en/latest/tutorials/composing_multipanel_figures.html) is very useful.
```python
from svgutils.compose import *
def get_standard_300x300(file_name):
fig = Figure(300, 300,
Panel(
SVG(file_name),
).move(-3, 12),
)
return fig
get_standard_300x300('logo-transparent.svg').save('300x300.svg')
# def get_other_size_from_standard(file_name, px):
# fig = Figure(px, px,
# Panel(
# SVG(file_name).scale(px/300.0),
# ).move(-3*px/300.0, 12*px/300.0),
# )
# return fig
# get_other_size_from_standard('logo.svg',256).save('256x256.svg')
```
```bash
# results
300x300.svg
```
4. use `inkscape` command line to get different sizes' `.png` files.
```bash
inkscape 300x300.svg -o 300x300.png
inkscape 300x300.svg -o 50x50.png -w 50 -h 50
inkscape 300x300.svg -o 64x64.png -w 64 -h 64
inkscape 300x300.svg -o 256x256.png -w 256 -h 256
```
```bash
# results
50x50.png
64x64.png
256x256.png
```
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:daedd262170200cb5e8cc288898b40892771b3962cf2bc17dc443f1c82a4aa94
size 12886
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6b33b2a71f2d91988416c17015b59ef205dbf6a8c704ecac1cc95ed03ccc2c8e
size 16537
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:98358aa726dcb5565164ff260634f5c527223d1f2ec1d0410b400625c8dad704
size 14082
+3
View File
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:02a5057ebe38e4c3835c8354f53aca01c4c2335d8a0435e22f509b4ca4a73ca4
size 12889
+34
View File
@@ -0,0 +1,34 @@
# Encryption
If a password is set, the files are encrypted before being sent to the cloud.
The encryption algorithm is delibrately designed to be aligned with openssl format.
1. The encryption algorithm is implemented using web-crypto.
2. The file content is encrypted using openssl format. Assuming a file named `sometext.txt`, a password `somepassword`, then the encryption is equivalent to the following command:
```bash
# file content encryption (ignoring file path encryption)
openssl enc -p -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:somepassword -in ./sometext.txt -out ./sometext.txt.enc
# file content decryption (ignoring file path decryption)
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 `base64url without padding` is applied.
Assuming the file path is `a-folder-文件夹/a-file-文件.md`, then the following commands are equivilent:
```bash
# 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 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.
+19
View File
@@ -0,0 +1,19 @@
## Bonus: Import And Export Not-Oauth2 Plugin Settings By QR Code
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 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:
1. Configure the plugin settings on one device, and make sure the sync functions work. (E.g, you could sync notes using S3 credentials.)
2. Open plugin settings page. Then scroll down the page, until the section "Import and Export Settings". Click the button "Get QR Code". A new modal should show up and you should see a QR code.
3. On a second device, make sure the vault name is the same as the first device's.
4. On that second device, use its camera app, or any apps that support scanning QR codes, to scan the QR code from the first device. (On latest iOS, the system's built in camera app should work. On Android, at least one open source app [Binary Eye](https://github.com/markusfisch/BinaryEye) is tested to be working.)
5. A link / url / address should be identified in the scan-QR-code app, and you could follow the instruction in the app, and then you should be redirected to open the Obsidian app.
6. And finally, there should be a new notice in the Obsidian app saying the settings are successfully imported. Otherwise please check the error message in the notice.
+36
View File
@@ -0,0 +1,36 @@
# Minimal Intrusive Design
The plugin tries to avoid saving additional meta data remotely.
## Benefits
Then the plugin doesn't make any assumptions about information on the remote endpoint.
For example, it's possbile for a uses to manually upload a file to s3, and next time the plugin can download that file to the local device.
And it's also possible to combine another "sync-to-s3" solution (like, another software) on desktops, and this plugin on mobile devices, together.
## Flaws
The main issue comes from deletions (and renamings which is actually interpreted as "deletion-then-creation").
Consider this:
1. The user create and sync a file to the cloud on the 1st device.
2. Then download this file to the 2nd device.
3. And then delete this file on the 1st device.
4. And sync on the 1st device. The file on the cloud is also deleted.
5. And sync on the 2nd device. **The 2nd device would upload the file again to the cloud.**
In step 4, the file is marked "deleted" on the 1st device, and the 1st device send the command "delete this file on the cloud" to the cloud sevice (e.g. s3). Then the file on the cloud is also deleted. So far so good.
But, in step 5, because no meta data are saved on the cloud, the 2nd device doesn't know that the file are deleted. Instead, it thinks "the file was not synced to the cloud last time, so it's uploaded this time". So an unintentional upload occurs.
Currently no way to fix this if no meta data are saved remotely. The only workarounds are:
1. Delete the file on the 1st device, **before** syncing it to the cloud. Then the file never show up on the cloud or on the 2nd device.
2. Or, manually delete the file on 2nd device **before** step 5 in above situation.
## Future
This design may be changed in the feature, considering the flaws described above.
+1 -1
View File
@@ -1,4 +1,4 @@
# Algorithm
# Sync Algorithm
## Sources
+51
View File
@@ -0,0 +1,51 @@
import dotenv from "dotenv/config";
import esbuild from "esbuild";
import process from "process";
// import builtins from 'builtin-modules'
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
console.log(`esbuild version = ${esbuild.version}`);
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({
banner: {
js: banner,
},
loader: {
".svg": "text",
},
entryPoints: ["./src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"fs",
"crypto",
// ...builtins
],
format: "cjs",
watch: !prod,
target: "es2016",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
minify: prod,
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));
+4 -4
View File
@@ -1,9 +1,9 @@
{
"id": "obsidian-save-remote",
"name": "Save remote",
"version": "0.0.14",
"id": "remotely-save",
"name": "Remotely Save",
"version": "0.2.13",
"minAppVersion": "0.12.15",
"description": "This is yet another plugin allowing users to sync notes between local device and the cloud.",
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
"author": "fyears",
"authorUrl": "https://github.com/fyears",
"isDesktopOnly": false
+34 -6
View File
@@ -1,54 +1,82 @@
{
"name": "obsidian-save-remote",
"version": "0.0.14",
"name": "remotely-save",
"version": "0.2.13",
"description": "This is yet another sync plugin for Obsidian app.",
"scripts": {
"dev": "webpack --mode development --watch",
"dev2": "node esbuild.config.mjs",
"build2": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"build": "webpack --mode production",
"dev": "webpack --mode development --watch",
"format": "npx prettier --write .",
"clean": "npx rimraf main.js",
"test": "cross-env TS_NODE_COMPILER_OPTIONS={\\\"module\\\":\\\"commonjs\\\"} mocha -r ts-node/register 'tests/**/*.ts'"
},
"browser": {
"path": "path-browserify",
"process": "process/browser",
"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/feather-icons": "^4.7.0",
"@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",
"@types/qrcode": "^1.4.1",
"builtin-modules": "^3.2.0",
"chai": "^4.3.4",
"chai-as-promised": "^7.1.1",
"cross-env": "^7.0.3",
"dotenv": "^10.0.0",
"esbuild": "^0.14.3",
"jsdom": "^19.0.0",
"mocha": "^9.1.3",
"prettier": "^2.4.1",
"terser-webpack-plugin": "^5.2.4",
"ts-loader": "^9.2.6",
"ts-node": "^10.4.0",
"tslib": "^2.2.0",
"typescript": "^4.4.4",
"webdav-server": "^2.6.2",
"webpack": "^5.58.2",
"webpack": "^5.64.4",
"webpack-cli": "^4.9.1"
},
"dependencies": {
"@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",
"@fyears/tsqueue": "^1.0.1",
"@microsoft/microsoft-graph-client": "^3.0.1",
"acorn": "^8.5.0",
"assert": "^2.0.0",
"aws-crt": "^1.10.1",
"buffer": "^6.0.3",
"codemirror": "^5.63.1",
"lovefield-ts": "^0.7.0",
"crypto-browserify": "^3.12.0",
"dropbox": "^10.22.0",
"feather-icons": "^4.28.0",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"loglevel": "^1.8.0",
"mime-types": "^2.1.33",
"nanoid": "^3.1.30",
"obsidian": "^0.12.0",
"path-browserify": "^1.0.1",
"process": "^0.11.10",
"qrcode": "^1.5.0",
"rfc4648": "^1.5.0",
"rimraf": "^3.0.2",
"stream-browserify": "^3.0.0",
"util": "^0.12.4",
"webdav": "^4.7.0",
"webdav-fs": "^4.0.0",
"xregexp": "^5.1.0"
+82
View File
@@ -0,0 +1,82 @@
/**
* Only type defs here.
* To avoid circular dependency.
*/
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "dropbox" | "onedrive";
export interface S3Config {
s3Endpoint: string;
s3Region: string;
s3AccessKeyID: string;
s3SecretAccessKey: string;
s3BucketName: string;
}
export interface DropboxConfig {
accessToken: string;
clientID: string;
refreshToken: string;
accessTokenExpiresInSeconds: number;
accessTokenExpiresAtTime: number;
accountID: string;
username: string;
credentialsShouldBeDeletedAtTime?: number;
}
export type WebdavAuthType = "digest" | "basic";
export interface WebdavConfig {
address: string;
username: string;
password: string;
authType: WebdavAuthType;
manualRecursive: boolean;
}
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;
currLogLevel?: string;
vaultRandomID?: string;
autoRunEveryMilliseconds?: number;
}
export interface RemoteItem {
key: string;
lastModified: number;
size: number;
remoteType: SUPPORTED_SERVICES_TYPE;
etag?: string;
}
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;
vault?: string;
ver?: string;
data?: string;
}
// 80 days
export const OAUTH2_FORCE_EXPIRE_MILLISECONDS = 1000 * 60 * 60 * 24 * 80;
+70
View File
@@ -0,0 +1,70 @@
import { base64, base64url } from "rfc4648";
import { reverseString } from "./misc";
import type { RemotelySavePluginSettings } from "./baseTypes";
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
const DEFAULT_README: string =
"Do NOT modify this manually. It's generated automatically.";
interface MessyConfigType {
readme: string;
d: string;
}
/**
* this should accept the result after loadData();
*/
export const messyConfigToNormal = (
x: MessyConfigType | RemotelySavePluginSettings | null | undefined
): RemotelySavePluginSettings | null | undefined => {
log.debug("loading, original config on disk:");
log.debug(x);
if (x === null || x === undefined) {
log.debug("the messy config is null or undefined, skip");
return x as any;
}
if ("readme" in x && "d" in x) {
// we should decode
const y = JSON.parse(
(
base64url.parse(reverseString(x["d"]), {
out: Buffer.allocUnsafe as any,
loose: true,
}) as Buffer
).toString("utf-8")
);
log.debug("loading, parsed config is:");
log.debug(y);
return y;
} else {
// return as is
log.debug("loading, parsed config is the same");
return x;
}
};
/**
* this should accept the result of original config
*/
export const normalConfigToMessy = (
x: RemotelySavePluginSettings | null | undefined
) => {
if (x === null || x === undefined) {
log.debug("the normal config is null or undefined, skip");
return x;
}
const y = {
readme: DEFAULT_README,
d: reverseString(
base64url.stringify(Buffer.from(JSON.stringify(x), "utf-8"), {
pad: false,
})
),
};
log.debug("encoding, encoded config is:");
log.debug(y);
return y;
};
+13 -14
View File
@@ -1,25 +1,24 @@
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
import * as lf from "lovefield-ts/dist/es6/lf.js";
import type { SyncPlanType } from "./sync";
import {
insertSyncPlanRecord,
clearAllSyncPlanRecords,
readAllSyncPlanRecordTexts,
} from "./localdb";
import { readAllSyncPlanRecordTextsByVault } from "./localdb";
import type { InternalDBs } from "./localdb";
import { mkdirpInVault } from "./misc";
const DEFAULT_DEBUG_FOLDER = "_debug_save_remote/";
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
const DEFAULT_DEBUG_FOLDER = "_debug_remotely_save/";
const DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX = "sync_plans_hist_exported_on_";
export const exportSyncPlansToFiles = async (
db: lf.DatabaseConnection,
vault: Vault
export const exportVaultSyncPlansToFiles = async (
db: InternalDBs,
vault: Vault,
vaultRandomID: string
) => {
console.log("exporting");
log.info("exporting");
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
const records = await readAllSyncPlanRecordTexts(db);
const records = await readAllSyncPlanRecordTextsByVault(db, vaultRandomID);
let md = "";
if (records.length === 0) {
md = "No sync plans history found";
@@ -33,5 +32,5 @@ export const exportSyncPlansToFiles = async (
await vault.create(filePath, md, {
mtime: ts,
});
console.log("finish exporting");
log.info("finish exporting");
};
+39 -10
View File
@@ -1,15 +1,15 @@
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 = 10000;
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
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 +120,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 +130,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
)
+84
View File
@@ -0,0 +1,84 @@
import QRCode from "qrcode";
import cloneDeep from "lodash/cloneDeep";
import {
COMMAND_URI,
UriParams,
RemotelySavePluginSettings,
} from "./baseTypes";
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
export const exportQrCodeUri = async (
settings: RemotelySavePluginSettings,
currentVaultName: string,
pluginVersion: string
) => {
const settings2 = cloneDeep(settings);
delete settings2.dropbox;
delete settings2.onedrive;
delete settings2.vaultRandomID;
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}`;
// log.info(uri)
const imgUri = await QRCode.toDataURL(rawUri);
return {
rawUri,
imgUri,
};
};
export interface ProcessQrCodeResultType {
status: "error" | "ok";
message: string;
result?: RemotelySavePluginSettings;
}
export const importQrCodeUri = (
inputParams: any,
currentVaultName: string
): ProcessQrCodeResultType => {
let params = inputParams as UriParams;
if (
params.func === undefined ||
params.func !== "settings" ||
params.vault === undefined ||
params.data === undefined
) {
return {
status: "error",
message: `the uri is not for exporting/importing settings: ${JSON.stringify(
inputParams
)}`,
};
}
if (params.vault !== currentVaultName) {
return {
status: "error",
message: `the target vault is ${
params.vault
} but you are currently in ${currentVaultName}: ${JSON.stringify(
inputParams
)}`,
};
}
let settings = {} as RemotelySavePluginSettings;
try {
settings = JSON.parse(params.data);
} catch (e) {
return {
status: "error",
message: `errors while parsing settings: ${JSON.stringify(inputParams)}`,
};
}
return {
status: "ok",
message: "ok",
result: settings,
};
};
+290 -189
View File
@@ -1,12 +1,18 @@
import * as lf from "lovefield-ts/dist/es6/lf.js";
import localforage from "localforage";
import { TAbstractFile, TFile, TFolder } from "obsidian";
import type { SUPPORTED_SERVICES_TYPE } from "./misc";
import type { SUPPORTED_SERVICES_TYPE } from "./baseTypes";
import type { SyncPlanType } from "./sync";
export type DatabaseConnection = lf.DatabaseConnection;
export type LocalForage = typeof localforage;
export const DEFAULT_DB_NAME = "saveremotedb";
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
const DB_VERSION_NUMBER_IN_HISTORY = [20211114, 20220108];
export const DEFAULT_DB_VERSION_NUMBER: number = 20220108;
export const DEFAULT_DB_NAME = "remotelysavedb";
export const DEFAULT_TBL_VERSION = "schemaversion";
export const DEFAULT_TBL_DELETE_HISTORY = "filefolderoperationhistory";
export const DEFAULT_TBL_SYNC_MAPPING = "syncmetadatahistory";
export const DEFAULT_SYNC_PLANS_HISTORY = "syncplanshistory";
@@ -16,87 +22,188 @@ export interface FileFolderHistoryRecord {
ctime: number;
mtime: number;
size: number;
action_when: number;
action_type: "delete" | "rename";
key_type: "folder" | "file";
rename_to: string;
actionWhen: number;
actionType: "delete" | "rename";
keyType: "folder" | "file";
renameTo: string;
vaultRandomID: string;
}
export interface SyncMetaMappingRecord {
local_key: string;
remote_key: string;
local_size: number;
remote_size: number;
local_mtime: number;
remote_mtime: number;
remote_extra_key: string;
remote_type: SUPPORTED_SERVICES_TYPE;
key_type: "folder" | "file";
interface SyncMetaMappingRecord {
localKey: string;
remoteKey: string;
localSize: number;
remoteSize: number;
localMtime: number;
remoteMtime: number;
remoteExtraKey: string;
remoteType: SUPPORTED_SERVICES_TYPE;
keyType: "folder" | "file";
vaultRandomID: string;
}
interface SyncPlanRecord {
ts: number;
remote_type: string;
sync_plan: string;
remoteType: string;
syncPlan: string;
vaultRandomID: string;
}
export const prepareDBs = async () => {
const schemaBuilder = lf.schema.create(DEFAULT_DB_NAME, 1);
schemaBuilder
.createTable(DEFAULT_TBL_DELETE_HISTORY)
.addColumn("id", lf.Type.INTEGER)
.addColumn("key", lf.Type.STRING)
.addColumn("ctime", lf.Type.INTEGER)
.addColumn("mtime", lf.Type.INTEGER)
.addColumn("size", lf.Type.INTEGER)
.addColumn("action_when", lf.Type.INTEGER)
.addColumn("action_type", lf.Type.STRING)
.addColumn("key_type", lf.Type.STRING)
.addPrimaryKey(["id"], true)
.addIndex("idxKey", ["key"]);
export interface InternalDBs {
versionTbl: LocalForage;
deleteHistoryTbl: LocalForage;
syncMappingTbl: LocalForage;
syncPlansTbl: LocalForage;
}
schemaBuilder
.createTable(DEFAULT_TBL_SYNC_MAPPING)
.addColumn("id", lf.Type.INTEGER)
.addColumn("local_key", lf.Type.STRING)
.addColumn("remote_key", lf.Type.STRING)
.addColumn("local_size", lf.Type.INTEGER)
.addColumn("remote_size", lf.Type.INTEGER)
.addColumn("local_mtime", lf.Type.INTEGER)
.addColumn("remote_mtime", lf.Type.INTEGER)
.addColumn("key_type", lf.Type.STRING)
.addColumn("remote_extra_key", lf.Type.STRING)
.addColumn("remote_type", lf.Type.STRING)
.addNullable([
"remote_extra_key",
"remote_mtime",
"remote_size",
"local_mtime",
])
.addPrimaryKey(["id"], true)
.addIndex("idxkey", ["local_key", "remote_key"]);
/**
* This migration mainly aims to assign vault name or vault id into all tables.
* @param db
* @param vaultRandomID
*/
const migrateDBsFrom20211114To20220108 = async (
db: InternalDBs,
vaultRandomID: string
) => {
const oldVer = 20211114;
const newVer = 20220108;
log.debug(`start upgrading internal db from ${oldVer} to ${newVer}`);
schemaBuilder
.createTable(DEFAULT_SYNC_PLANS_HISTORY)
.addColumn("id", lf.Type.INTEGER)
.addColumn("ts", lf.Type.INTEGER)
.addColumn("remote_type", lf.Type.STRING)
.addColumn("sync_plan", lf.Type.STRING)
.addPrimaryKey(["id"], true)
.addIndex("tskey", ["ts"]);
const allPromisesToWait: Promise<any>[] = [];
const db = await schemaBuilder.connect({
storeType: lf.DataStoreType.INDEXED_DB,
});
console.log("db connected");
log.debug("assign vault id to any delete history");
const keysInDeleteHistoryTbl = await db.deleteHistoryTbl.keys();
for (const key of keysInDeleteHistoryTbl) {
if (key.startsWith(vaultRandomID)) {
continue;
}
const value = (await db.deleteHistoryTbl.getItem(
key
)) as FileFolderHistoryRecord;
if (value === null || value === undefined) {
continue;
}
if (value.vaultRandomID === undefined || value.vaultRandomID === "") {
value.vaultRandomID = vaultRandomID;
}
const newKey = `${vaultRandomID}\t${key}`;
allPromisesToWait.push(db.deleteHistoryTbl.setItem(newKey, value));
allPromisesToWait.push(db.deleteHistoryTbl.removeItem(key));
}
log.debug("assign vault id to any sync mapping");
const keysInSyncMappingTbl = await db.syncMappingTbl.keys();
for (const key of keysInSyncMappingTbl) {
if (key.startsWith(vaultRandomID)) {
continue;
}
const value = (await db.syncMappingTbl.getItem(
key
)) as SyncMetaMappingRecord;
if (value === null || value === undefined) {
continue;
}
if (value.vaultRandomID === undefined || value.vaultRandomID === "") {
value.vaultRandomID = vaultRandomID;
}
const newKey = `${vaultRandomID}\t${key}`;
allPromisesToWait.push(db.syncMappingTbl.setItem(newKey, value));
allPromisesToWait.push(db.syncMappingTbl.removeItem(key));
}
log.debug("assign vault id to any sync plan records");
const keysInSyncPlansTbl = await db.syncPlansTbl.keys();
for (const key of keysInSyncPlansTbl) {
if (key.startsWith(vaultRandomID)) {
continue;
}
const value = (await db.syncPlansTbl.getItem(key)) as SyncPlanRecord;
if (value === null || value === undefined) {
continue;
}
if (value.vaultRandomID === undefined || value.vaultRandomID === "") {
value.vaultRandomID = vaultRandomID;
}
const newKey = `${vaultRandomID}\t${key}`;
allPromisesToWait.push(db.syncPlansTbl.setItem(newKey, value));
allPromisesToWait.push(db.syncPlansTbl.removeItem(key));
}
log.debug("finally update version if everything is ok");
await Promise.all(allPromisesToWait);
await db.versionTbl.setItem("version", newVer);
log.debug(`finish upgrading internal db from ${oldVer} to ${newVer}`);
};
const migrateDBs = async (
db: InternalDBs,
oldVer: number,
newVer: number,
vaultRandomID: string
) => {
if (oldVer === newVer) {
return;
}
if (oldVer === 20211114 && newVer === 20220108) {
return await migrateDBsFrom20211114To20220108(db, vaultRandomID);
}
// not implemented
throw Error(`not supported internal db changes from ${oldVer} to ${newVer}`);
};
export const prepareDBs = async (vaultRandomID: string) => {
const db = {
versionTbl: localforage.createInstance({
name: DEFAULT_DB_NAME,
storeName: DEFAULT_TBL_VERSION,
}),
deleteHistoryTbl: localforage.createInstance({
name: DEFAULT_DB_NAME,
storeName: DEFAULT_TBL_DELETE_HISTORY,
}),
syncMappingTbl: localforage.createInstance({
name: DEFAULT_DB_NAME,
storeName: DEFAULT_TBL_SYNC_MAPPING,
}),
syncPlansTbl: localforage.createInstance({
name: DEFAULT_DB_NAME,
storeName: DEFAULT_SYNC_PLANS_HISTORY,
}),
} as InternalDBs;
const originalVersion = (await db.versionTbl.getItem("version")) as number;
if (originalVersion === null) {
log.debug(
`no internal db version, setting it to ${DEFAULT_DB_VERSION_NUMBER}`
);
await db.versionTbl.setItem("version", DEFAULT_DB_VERSION_NUMBER);
} else if (originalVersion === DEFAULT_DB_VERSION_NUMBER) {
// do nothing
} else {
log.debug(
`trying to upgrade db version from ${originalVersion} to ${DEFAULT_DB_VERSION_NUMBER}`
);
await migrateDBs(
db,
originalVersion,
DEFAULT_DB_VERSION_NUMBER,
vaultRandomID
);
}
log.info("db connected");
return db;
};
export const destroyDBs = async (db: lf.DatabaseConnection) => {
db.close();
export const destroyDBs = async () => {
// await localforage.dropInstance({
// name: DEFAULT_DB_NAME,
// });
// log.info("db deleted");
const req = indexedDB.deleteDatabase(DEFAULT_DB_NAME);
req.onsuccess = (event) => {
console.log("db deleted");
log.info("db deleted");
};
req.onblocked = (event) => {
console.warn("trying to delete db but it was blocked");
@@ -107,38 +214,34 @@ export const destroyDBs = async (db: lf.DatabaseConnection) => {
};
};
export const loadDeleteRenameHistoryTable = async (
db: lf.DatabaseConnection
export const loadDeleteRenameHistoryTableByVault = async (
db: InternalDBs,
vaultRandomID: string
) => {
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
const records = await db
.select()
.from(schema)
.orderBy(schema.col("action_when"), lf.Order.ASC)
.exec();
return records as FileFolderHistoryRecord[];
const records = [] as FileFolderHistoryRecord[];
await db.deleteHistoryTbl.iterate((value, key, iterationNumber) => {
if (key.startsWith(`${vaultRandomID}\t`)) {
records.push(value as FileFolderHistoryRecord);
}
});
records.sort((a, b) => a.actionWhen - b.actionWhen); // ascending
return records;
};
export const clearDeleteRenameHistoryOfKey = async (
db: lf.DatabaseConnection,
key: string
export const clearDeleteRenameHistoryOfKeyAndVault = async (
db: InternalDBs,
key: string,
vaultRandomID: string
) => {
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
await db.delete().from(tbl).where(tbl.col("key").eq(key)).exec();
await db.deleteHistoryTbl.removeItem(`${vaultRandomID}\t${key}`);
};
export const insertDeleteRecord = async (
db: lf.DatabaseConnection,
fileOrFolder: TAbstractFile
export const insertDeleteRecordByVault = async (
db: InternalDBs,
fileOrFolder: TAbstractFile,
vaultRandomID: string
) => {
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
// console.log(fileOrFolder);
// log.info(fileOrFolder);
let k: FileFolderHistoryRecord;
if (fileOrFolder instanceof TFile) {
k = {
@@ -146,10 +249,11 @@ export const insertDeleteRecord = async (
ctime: fileOrFolder.stat.ctime,
mtime: fileOrFolder.stat.mtime,
size: fileOrFolder.stat.size,
action_when: Date.now(),
action_type: "delete",
key_type: "file",
rename_to: "",
actionWhen: Date.now(),
actionType: "delete",
keyType: "file",
renameTo: "",
vaultRandomID: vaultRandomID,
};
} else if (fileOrFolder instanceof TFolder) {
// key should endswith "/"
@@ -161,24 +265,23 @@ export const insertDeleteRecord = async (
ctime: 0,
mtime: 0,
size: 0,
action_when: Date.now(),
action_type: "delete",
key_type: "folder",
rename_to: "",
actionWhen: Date.now(),
actionType: "delete",
keyType: "folder",
renameTo: "",
vaultRandomID: vaultRandomID,
};
}
const row = tbl.createRow(k);
await db.insertOrReplace().into(tbl).values([row]).exec();
await db.deleteHistoryTbl.setItem(`${vaultRandomID}\t${k.key}`, k);
};
export const insertRenameRecord = async (
db: lf.DatabaseConnection,
export const insertRenameRecordByVault = async (
db: InternalDBs,
fileOrFolder: TAbstractFile,
oldPath: string
oldPath: string,
vaultRandomID: string
) => {
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
// console.log(fileOrFolder);
// log.info(fileOrFolder);
let k: FileFolderHistoryRecord;
if (fileOrFolder instanceof TFile) {
k = {
@@ -186,10 +289,11 @@ export const insertRenameRecord = async (
ctime: fileOrFolder.stat.ctime,
mtime: fileOrFolder.stat.mtime,
size: fileOrFolder.stat.size,
action_when: Date.now(),
action_type: "rename",
key_type: "file",
rename_to: fileOrFolder.path,
actionWhen: Date.now(),
actionType: "rename",
keyType: "file",
renameTo: fileOrFolder.path,
vaultRandomID: vaultRandomID,
};
} else if (fileOrFolder instanceof TFolder) {
const key = oldPath.endsWith("/") ? oldPath : `${oldPath}/`;
@@ -201,116 +305,113 @@ export const insertRenameRecord = async (
ctime: 0,
mtime: 0,
size: 0,
action_when: Date.now(),
action_type: "rename",
key_type: "folder",
rename_to: renameTo,
actionWhen: Date.now(),
actionType: "rename",
keyType: "folder",
renameTo: renameTo,
vaultRandomID: vaultRandomID,
};
}
const row = tbl.createRow(k);
await db.insertOrReplace().into(tbl).values([row]).exec();
await db.deleteHistoryTbl.setItem(`${vaultRandomID}\t${k.key}`, k);
};
export const getAllDeleteRenameRecords = async (db: lf.DatabaseConnection) => {
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
const res1 = await db.select().from(schema).exec();
const res2 = res1 as FileFolderHistoryRecord[];
return res2;
};
export const upsertSyncMetaMappingDataS3 = async (
db: lf.DatabaseConnection,
export const upsertSyncMetaMappingDataByVault = async (
serviceType: SUPPORTED_SERVICES_TYPE,
db: InternalDBs,
localKey: string,
localMTime: number,
localSize: number,
remoteKey: string,
remoteMTime: number,
remoteSize: number,
remoteExtraKey: string /* ETag from s3 */
remoteExtraKey: string,
vaultRandomID: string
) => {
const schema = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
const aggregratedInfo: SyncMetaMappingRecord = {
local_key: localKey,
local_mtime: localMTime,
local_size: localSize,
remote_key: remoteKey,
remote_mtime: remoteMTime,
remote_size: remoteSize,
remote_extra_key: remoteExtraKey,
remote_type: "s3",
key_type: localKey.endsWith("/") ? "folder" : "file",
localKey: localKey,
localMtime: localMTime,
localSize: localSize,
remoteKey: remoteKey,
remoteMtime: remoteMTime,
remoteSize: remoteSize,
remoteExtraKey: remoteExtraKey,
remoteType: serviceType,
keyType: localKey.endsWith("/") ? "folder" : "file",
vaultRandomID: vaultRandomID,
};
const row = schema.createRow(aggregratedInfo);
await db.insertOrReplace().into(schema).values([row]).exec();
await db.syncMappingTbl.setItem(
`${vaultRandomID}\t${remoteKey}`,
aggregratedInfo
);
};
export const getSyncMetaMappingByRemoteKeyS3 = async (
db: lf.DatabaseConnection,
export const getSyncMetaMappingByRemoteKeyAndVault = async (
serviceType: SUPPORTED_SERVICES_TYPE,
db: InternalDBs,
remoteKey: string,
remoteMTime: number,
remoteExtraKey: string
remoteExtraKey: string,
vaultRandomID: string
) => {
const schema = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
const tbl = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
const res = (await db
.select()
.from(tbl)
.where(
lf.op.and(
tbl.col("remote_key").eq(remoteKey),
tbl.col("remote_mtime").eq(remoteMTime),
tbl.col("remote_extra_key").eq(remoteExtraKey),
tbl.col("remote_type").eq("s3")
)
)
.exec()) as SyncMetaMappingRecord[];
const potentialItem = (await db.syncMappingTbl.getItem(
`${vaultRandomID}\t${remoteKey}`
)) as SyncMetaMappingRecord;
if (res.length === 1) {
return res[0];
}
if (res.length === 0) {
if (potentialItem === null) {
// no result was found
return undefined;
}
throw Error("something bad in sync meta mapping!");
if (
potentialItem.remoteKey === remoteKey &&
potentialItem.remoteMtime === remoteMTime &&
potentialItem.remoteExtraKey === remoteExtraKey &&
potentialItem.remoteType === serviceType
) {
// the result was found
return potentialItem;
} else {
return undefined;
}
};
export const clearAllSyncMetaMapping = async (db: lf.DatabaseConnection) => {
const tbl = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
await db.delete().from(tbl).exec();
export const clearAllSyncMetaMapping = async (db: InternalDBs) => {
await db.syncMappingTbl.clear();
};
export const insertSyncPlanRecord = async (
db: lf.DatabaseConnection,
syncPlan: SyncPlanType
export const insertSyncPlanRecordByVault = async (
db: InternalDBs,
syncPlan: SyncPlanType,
vaultRandomID: string
) => {
const schema = db.getSchema().table(DEFAULT_SYNC_PLANS_HISTORY);
const row = schema.createRow({
const record = {
ts: syncPlan.ts,
remote_type: syncPlan.remoteType,
sync_plan: JSON.stringify(syncPlan, null, 2),
} as SyncPlanRecord);
await db.insertOrReplace().into(schema).values([row]).exec();
vaultRandomID: vaultRandomID,
remoteType: syncPlan.remoteType,
syncPlan: JSON.stringify(syncPlan /* directly stringify */, null, 2),
} as SyncPlanRecord;
await db.syncPlansTbl.setItem(`${vaultRandomID}\t${syncPlan.ts}`, record);
};
export const clearAllSyncPlanRecords = async (db: lf.DatabaseConnection) => {
const tbl = db.getSchema().table(DEFAULT_SYNC_PLANS_HISTORY);
await db.delete().from(tbl).exec();
export const clearAllSyncPlanRecords = async (db: InternalDBs) => {
await db.syncPlansTbl.clear();
};
export const readAllSyncPlanRecordTexts = async (db: lf.DatabaseConnection) => {
const schema = db.getSchema().table(DEFAULT_SYNC_PLANS_HISTORY);
const records = (await db
.select()
.from(schema)
.orderBy(schema.col("ts"), lf.Order.DESC)
.exec()) as SyncPlanRecord[];
export const readAllSyncPlanRecordTextsByVault = async (
db: InternalDBs,
vaultRandomID: string
) => {
const records = [] as SyncPlanRecord[];
await db.syncPlansTbl.iterate((value, key, iterationNumber) => {
if (key.startsWith(`${vaultRandomID}\t`)) {
records.push(value as SyncPlanRecord);
}
});
records.sort((a, b) => -(a.ts - b.ts)); // descending
if (records === undefined) {
return [] as string[];
} else {
return records.map((x) => x.sync_plan);
return records.map((x) => x.syncPlan);
}
};
+561 -362
View File
@@ -1,423 +1,622 @@
import { Modal, Notice, Plugin, Setting, addIcon, setIcon } from "obsidian";
import cloneDeep from "lodash/cloneDeep";
import { nanoid } from "nanoid";
import feather from "feather-icons";
import type { RemotelySavePluginSettings } from "./baseTypes";
import {
App,
Modal,
Notice,
Plugin,
PluginSettingTab,
Setting,
request,
Platform,
TFile,
TFolder,
} from "obsidian";
import * as CodeMirror from "codemirror";
import {
clearAllSyncPlanRecords,
clearAllSyncMetaMapping,
DatabaseConnection,
} from "./localdb";
COMMAND_CALLBACK,
COMMAND_CALLBACK_ONEDRIVE,
COMMAND_CALLBACK_DROPBOX,
COMMAND_URI,
} from "./baseTypes";
import { importQrCodeUri } from "./importExport";
import type { InternalDBs } from "./localdb";
import {
insertDeleteRecordByVault,
insertRenameRecordByVault,
insertSyncPlanRecordByVault,
loadDeleteRenameHistoryTableByVault,
prepareDBs,
destroyDBs,
loadDeleteRenameHistoryTable,
insertDeleteRecord,
insertRenameRecord,
getAllDeleteRenameRecords,
insertSyncPlanRecord,
} from "./localdb";
import type { SyncStatusType, PasswordCheckType } from "./sync";
import { isPasswordOk, getSyncPlan, doActualSync } from "./sync";
import { RemoteClient } from "./remote";
import {
DEFAULT_S3_CONFIG,
getS3Client,
listFromRemote,
S3Config,
checkS3Connectivity,
} from "./s3";
import { exportSyncPlansToFiles } from "./debugMode";
DEFAULT_DROPBOX_CONFIG,
getAuthUrlAndVerifier as getAuthUrlAndVerifierDropbox,
sendAuthReq as sendAuthReqDropbox,
setConfigBySuccessfullAuthInplace as setConfigBySuccessfullAuthInplaceDropbox,
} from "./remoteForDropbox";
import {
AccessCodeResponseSuccessfulType,
DEFAULT_ONEDRIVE_CONFIG,
sendAuthReq as sendAuthReqOnedrive,
setConfigBySuccessfullAuthInplace as setConfigBySuccessfullAuthInplaceOnedrive,
} from "./remoteForOnedrive";
import { DEFAULT_S3_CONFIG } from "./remoteForS3";
import { DEFAULT_WEBDAV_CONFIG } from "./remoteForWebdav";
import { RemotelySaveSettingTab } from "./settings";
import type { SyncStatusType } from "./sync";
import { doActualSync, getSyncPlan, isPasswordOk } from "./sync";
import { messyConfigToNormal, normalConfigToMessy } from "./configPersist";
interface SaveRemotePluginSettings {
s3?: S3Config;
password?: string;
}
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
const DEFAULT_SETTINGS: SaveRemotePluginSettings = {
const DEFAULT_SETTINGS: RemotelySavePluginSettings = {
s3: DEFAULT_S3_CONFIG,
webdav: DEFAULT_WEBDAV_CONFIG,
dropbox: DEFAULT_DROPBOX_CONFIG,
onedrive: DEFAULT_ONEDRIVE_CONFIG,
password: "",
serviceType: "s3",
currLogLevel: "info",
vaultRandomID: "",
autoRunEveryMilliseconds: -1,
};
export default class SaveRemotePlugin extends Plugin {
settings: SaveRemotePluginSettings;
cm: CodeMirror.Editor;
db: DatabaseConnection;
interface OAuth2Info {
verifier?: string;
helperModal?: Modal;
authDiv?: HTMLElement;
revokeDiv?: HTMLElement;
revokeAuthSetting?: Setting;
}
type SyncTriggerSourceType = "manual" | "auto";
const iconNameSyncWait = `remotely-save-sync-wait`;
const iconNameSyncRunning = `remotely-save-sync-running`;
const iconSvgSyncWait = feather.icons["rotate-ccw"].toSvg({
width: 100,
height: 100,
});
const iconSvgSyncRunning = feather.icons["refresh-ccw"].toSvg({
width: 100,
height: 100,
});
export default class RemotelySavePlugin extends Plugin {
settings: RemotelySavePluginSettings;
// cm: CodeMirror.Editor;
db: InternalDBs;
syncStatus: SyncStatusType;
oauth2Info: OAuth2Info;
currLogLevel: string;
currSyncMsg?: string;
syncRibbon?: HTMLElement;
autoRunIntervalID?: number;
async syncRun(triggerSource: SyncTriggerSourceType = "manual") {
const getNotice = (x: string) => {
// only show notices in manual mode
// no notice in auto mode
if (triggerSource === "manual") {
new Notice(x);
}
};
if (this.syncStatus !== "idle") {
// here the notice is shown regardless of triggerSource
new Notice(`Remotely Save already running in stage ${this.syncStatus}!`);
if (this.currSyncMsg !== undefined && this.currSyncMsg !== "") {
new Notice(this.currSyncMsg);
}
return;
}
let originLabel = `${this.manifest.name}`;
if (this.syncRibbon !== undefined) {
originLabel = this.syncRibbon.getAttribute("aria-label");
}
try {
log.info(
`${
this.manifest.id
}-${Date.now()}: start sync, triggerSource=${triggerSource}`
);
if (this.syncRibbon !== undefined) {
setIcon(this.syncRibbon, iconNameSyncRunning);
this.syncRibbon.setAttribute(
"aria-label",
`${this.manifest.name}: ${triggerSource} syncing`
);
}
//log.info(`huh ${this.settings.password}`)
getNotice(
`1/7 Remotely Save Sync Preparing (${this.settings.serviceType})`
);
this.syncStatus = "preparing";
getNotice("2/7 Starting to fetch remote meta data.");
this.syncStatus = "getting_remote_meta";
const self = this;
const client = new RemoteClient(
this.settings.serviceType,
this.settings.s3,
this.settings.webdav,
this.settings.dropbox,
this.settings.onedrive,
this.app.vault.getName(),
() => self.saveSettings()
);
const remoteRsp = await client.listFromRemote();
log.info(remoteRsp);
getNotice("3/7 Starting to fetch local meta data.");
this.syncStatus = "getting_local_meta";
const local = this.app.vault.getAllLoadedFiles();
const localHistory = await loadDeleteRenameHistoryTableByVault(
this.db,
this.settings.vaultRandomID
);
// log.info(local);
// log.info(localHistory);
getNotice("4/7 Checking password correct or not.");
this.syncStatus = "checking_password";
const passwordCheckResult = await isPasswordOk(
remoteRsp.Contents,
this.settings.password
);
if (!passwordCheckResult.ok) {
getNotice("something goes wrong while checking password");
throw Error(passwordCheckResult.reason);
}
getNotice("5/7 Starting to generate sync plan.");
this.syncStatus = "generating_plan";
const syncPlan = await getSyncPlan(
remoteRsp.Contents,
local,
localHistory,
this.db,
this.settings.vaultRandomID,
client.serviceType,
this.settings.password
);
log.info(syncPlan.mixedStates); // for debugging
await insertSyncPlanRecordByVault(
this.db,
syncPlan,
this.settings.vaultRandomID
);
// The operations above are read only and kind of safe.
// The operations below begins to write or delete (!!!) something.
getNotice("6/7 Remotely Save Sync data exchanging!");
this.syncStatus = "syncing";
await doActualSync(
client,
this.db,
this.settings.vaultRandomID,
this.app.vault,
syncPlan,
this.settings.password,
(i: number, totalCount: number, pathName: string, decision: string) =>
self.setCurrSyncMsg(i, totalCount, pathName, decision)
);
getNotice("7/7 Remotely Save finish!");
this.currSyncMsg = "";
this.syncStatus = "finish";
this.syncStatus = "idle";
if (this.syncRibbon !== undefined) {
setIcon(this.syncRibbon, iconNameSyncWait);
this.syncRibbon.setAttribute("aria-label", originLabel);
}
log.info(
`${
this.manifest.id
}-${Date.now()}: finish sync, triggerSource=${triggerSource}`
);
} catch (error) {
const msg = `${
this.manifest.id
}-${Date.now()}: abort sync, triggerSource=${triggerSource}, error while ${
this.syncStatus
}`;
log.info(msg);
log.info(error);
getNotice(msg);
getNotice(error.message);
this.syncStatus = "idle";
if (this.syncRibbon !== undefined) {
setIcon(this.syncRibbon, iconNameSyncWait);
this.syncRibbon.setAttribute("aria-label", originLabel);
}
}
}
async onload() {
console.log("loading plugin obsidian-save-remote");
log.info(`loading plugin ${this.manifest.id}`);
addIcon(iconNameSyncWait, iconSvgSyncWait);
addIcon(iconNameSyncRunning, iconSvgSyncRunning);
this.oauth2Info = {
verifier: "",
helperModal: undefined,
authDiv: undefined,
revokeDiv: undefined,
revokeAuthSetting: undefined,
}; // init
this.currSyncMsg = "";
await this.loadSettings();
if (this.settings.currLogLevel !== undefined) {
log.setLevel(this.settings.currLogLevel as any);
}
await this.checkIfOauthExpires();
await this.checkIfVaultIDAssigned(); // MUST before prepareDB()
await this.prepareDB();
this.syncStatus = "idle";
this.registerEvent(
this.app.vault.on("delete", async (fileOrFolder) => {
await insertDeleteRecord(this.db, fileOrFolder);
await insertDeleteRecordByVault(
this.db,
fileOrFolder,
this.settings.vaultRandomID
);
})
);
this.registerEvent(
this.app.vault.on("rename", async (fileOrFolder, oldPath) => {
await insertRenameRecord(this.db, fileOrFolder, oldPath);
await insertRenameRecordByVault(
this.db,
fileOrFolder,
oldPath,
this.settings.vaultRandomID
);
})
);
this.addRibbonIcon("switch", "Save Remote", async () => {
if (this.syncStatus !== "idle") {
new Notice(`Save Remote already running in stage ${this.syncStatus}!`);
return;
}
try {
//console.log(`huh ${this.settings.password}`)
new Notice("1/6 Save Remote Sync Preparing");
this.syncStatus = "preparing";
new Notice("2/6 Starting to fetch remote meta data.");
this.syncStatus = "getting_remote_meta";
const s3Client = getS3Client(this.settings.s3);
const remoteRsp = await listFromRemote(s3Client, this.settings.s3);
new Notice("3/6 Starting to fetch local meta data.");
this.syncStatus = "getting_local_meta";
const local = this.app.vault.getAllLoadedFiles();
const localHistory = await loadDeleteRenameHistoryTable(this.db);
// console.log(remoteRsp);
// console.log(local);
// console.log(localHistory);
new Notice("4/7 Checking password correct or not.");
this.syncStatus = "checking_password";
const passwordCheckResult = await isPasswordOk(
remoteRsp.Contents,
this.settings.password
this.registerObsidianProtocolHandler(COMMAND_URI, async (inputParams) => {
const parsed = importQrCodeUri(inputParams, this.app.vault.getName());
if (parsed.status === "error") {
new Notice(parsed.message);
} else {
const copied = cloneDeep(parsed.result);
// new Notice(JSON.stringify(copied))
this.settings = Object.assign({}, this.settings, copied);
this.saveSettings();
new Notice(
`New not-oauth2 settings for ${this.manifest.name} saved. Reopen the plugin Settings to the effect.`
);
if (!passwordCheckResult.ok) {
new Notice("something goes wrong while checking password");
throw Error(passwordCheckResult.reason);
}
});
this.registerObsidianProtocolHandler(
COMMAND_CALLBACK,
async (inputParams) => {
new Notice(
`Your uri call a callback that's not supported yet: ${JSON.stringify(
inputParams
)}`
);
}
);
this.registerObsidianProtocolHandler(
COMMAND_CALLBACK_DROPBOX,
async (inputParams) => {
if (inputParams.code !== undefined) {
if (this.oauth2Info.helperModal !== undefined) {
this.oauth2Info.helperModal.contentEl.empty();
this.oauth2Info.helperModal.contentEl.createEl("p", {
text: "Connecting to Dropbox...",
});
this.oauth2Info.helperModal.contentEl.createEl("p", {
text: "Please DO NOT close this modal.",
});
}
let authRes = await sendAuthReqDropbox(
this.settings.dropbox.clientID,
this.oauth2Info.verifier,
inputParams.code
);
const self = this;
setConfigBySuccessfullAuthInplaceDropbox(
this.settings.dropbox,
authRes,
() => self.saveSettings()
);
const client = new RemoteClient(
"dropbox",
undefined,
undefined,
this.settings.dropbox,
undefined,
this.app.vault.getName(),
() => self.saveSettings()
);
const username = await client.getUser();
this.settings.dropbox.username = username;
await this.saveSettings();
new Notice(`Good! We've connected to Dropbox as user ${username}!`);
this.oauth2Info.verifier = ""; // reset it
this.oauth2Info.helperModal?.close(); // close it
this.oauth2Info.helperModal = undefined;
this.oauth2Info.authDiv?.toggleClass(
"dropbox-auth-button-hide",
this.settings.dropbox.username !== ""
);
this.oauth2Info.authDiv = undefined;
this.oauth2Info.revokeAuthSetting?.setDesc(
`You've connected as user ${this.settings.dropbox.username}. If you want to disconnect, click this button.`
);
this.oauth2Info.revokeAuthSetting = undefined;
this.oauth2Info.revokeDiv?.toggleClass(
"dropbox-revoke-auth-button-hide",
this.settings.dropbox.username === ""
);
this.oauth2Info.revokeDiv = undefined;
} else {
new Notice(
"Something went wrong from response from Dropbox. Maybe you rejected the auth?"
);
throw Error(
`do not know how to deal with the callback: ${JSON.stringify(
inputParams
)}`
);
}
new Notice("5/7 Starting to generate sync plan.");
this.syncStatus = "generating_plan";
const syncPlan = await getSyncPlan(
remoteRsp.Contents,
local,
localHistory,
this.db,
this.settings.password
);
console.log(syncPlan.mixedStates); // for debugging
await insertSyncPlanRecord(this.db, syncPlan);
// The operations above are read only and kind of safe.
// The operations below begins to write or delete (!!!) something.
new Notice("6/7 Save Remote Sync data exchanging!");
this.syncStatus = "syncing";
await doActualSync(
s3Client,
this.settings.s3,
this.db,
this.app.vault,
syncPlan,
this.settings.password
);
new Notice("7/7 Save Remote finish!");
this.syncStatus = "finish";
this.syncStatus = "idle";
} catch (error) {
const msg = `Save Remote error while ${this.syncStatus}`;
console.log(msg);
console.log(error);
new Notice(msg);
new Notice(error.message);
this.syncStatus = "idle";
}
);
this.registerObsidianProtocolHandler(
COMMAND_CALLBACK_ONEDRIVE,
async (inputParams) => {
if (inputParams.code !== undefined) {
if (this.oauth2Info.helperModal !== undefined) {
this.oauth2Info.helperModal.contentEl.empty();
this.oauth2Info.helperModal.contentEl.createEl("p", {
text: "Connecting to Onedrive...",
});
this.oauth2Info.helperModal.contentEl.createEl("p", {
text: "Please DO NOT close this modal.",
});
}
let rsp = await sendAuthReqOnedrive(
this.settings.onedrive.clientID,
this.settings.onedrive.authority,
inputParams.code,
this.oauth2Info.verifier
);
if ((rsp as any).error !== undefined) {
throw Error(`${JSON.stringify(rsp)}`);
}
const self = this;
setConfigBySuccessfullAuthInplaceOnedrive(
this.settings.onedrive,
rsp as AccessCodeResponseSuccessfulType,
() => self.saveSettings()
);
const client = new RemoteClient(
"onedrive",
undefined,
undefined,
undefined,
this.settings.onedrive,
this.app.vault.getName(),
() => self.saveSettings()
);
this.settings.onedrive.username = await client.getUser();
await this.saveSettings();
this.oauth2Info.verifier = ""; // reset it
this.oauth2Info.helperModal?.close(); // close it
this.oauth2Info.helperModal = undefined;
this.oauth2Info.authDiv?.toggleClass(
"onedrive-auth-button-hide",
this.settings.onedrive.username !== ""
);
this.oauth2Info.authDiv = undefined;
this.oauth2Info.revokeAuthSetting?.setDesc(
`You've connected as user ${this.settings.onedrive.username}. If you want to disconnect, click this button.`
);
this.oauth2Info.revokeAuthSetting = undefined;
this.oauth2Info.revokeDiv?.toggleClass(
"onedrive-revoke-auth-button-hide",
this.settings.onedrive.username === ""
);
this.oauth2Info.revokeDiv = undefined;
} else {
new Notice(
"Something went wrong from response from OneDrive. Maybe you rejected the auth?"
);
throw Error(
`do not know how to deal with the callback: ${JSON.stringify(
inputParams
)}`
);
}
}
);
this.syncRibbon = this.addRibbonIcon(
iconNameSyncWait,
`${this.manifest.name}`,
async () => this.syncRun("manual")
);
this.addCommand({
id: "start-sync",
name: "start sync",
icon: iconNameSyncWait,
callback: async () => {
this.syncRun("manual");
},
});
this.addSettingTab(new SaveRemoteSettingTab(this.app, this));
this.registerCodeMirror((cm: CodeMirror.Editor) => {
this.cm = cm;
console.log("codemirror registered.");
});
this.addSettingTab(new RemotelySaveSettingTab(this.app, this));
// this.registerDomEvent(document, "click", (evt: MouseEvent) => {
// console.log("click", evt);
// log.info("click", evt);
// });
// this.registerInterval(
// window.setInterval(() => console.log("setInterval"), 5 * 60 * 1000)
// );
if (
this.settings.autoRunEveryMilliseconds !== undefined &&
this.settings.autoRunEveryMilliseconds !== null &&
this.settings.autoRunEveryMilliseconds > 0
) {
const intervalID = window.setInterval(() => {
this.syncRun("auto");
}, this.settings.autoRunEveryMilliseconds);
this.autoRunIntervalID = intervalID;
this.registerInterval(intervalID);
}
}
onunload() {
console.log("unloading plugin obsidian-save-remote");
log.info(`unloading plugin ${this.manifest.id}`);
this.destroyDBs();
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.settings = Object.assign(
{},
cloneDeep(DEFAULT_SETTINGS),
messyConfigToNormal(await this.loadData())
);
if (this.settings.dropbox.clientID === "") {
this.settings.dropbox.clientID = DEFAULT_SETTINGS.dropbox.clientID;
}
if (this.settings.onedrive.clientID === "") {
this.settings.onedrive.clientID = DEFAULT_SETTINGS.onedrive.clientID;
}
if (this.settings.onedrive.authority === "") {
this.settings.onedrive.authority = DEFAULT_SETTINGS.onedrive.authority;
}
if (this.settings.webdav.manualRecursive === undefined) {
this.settings.webdav.manualRecursive = false;
}
}
async saveSettings() {
await this.saveData(this.settings);
await this.saveData(normalConfigToMessy(this.settings));
}
async checkIfOauthExpires() {
let needSave: boolean = false;
const current = Date.now();
// fullfill old version settings
if (
this.settings.dropbox.refreshToken !== "" &&
this.settings.dropbox.credentialsShouldBeDeletedAtTime === undefined
) {
// It has a refreshToken, but not expire time.
// Likely to be a setting from old version.
// we set it to a month.
this.settings.dropbox.credentialsShouldBeDeletedAtTime =
current + 1000 * 60 * 60 * 24 * 30;
needSave = true;
}
if (
this.settings.onedrive.refreshToken !== "" &&
this.settings.onedrive.credentialsShouldBeDeletedAtTime === undefined
) {
this.settings.onedrive.credentialsShouldBeDeletedAtTime =
current + 1000 * 60 * 60 * 24 * 30;
needSave = true;
}
// check expired or not
let dropboxExpired = false;
if (
this.settings.dropbox.refreshToken !== "" &&
current >= this.settings.dropbox.credentialsShouldBeDeletedAtTime
) {
dropboxExpired = true;
this.settings.dropbox = cloneDeep(DEFAULT_DROPBOX_CONFIG);
needSave = true;
}
let onedriveExpired = false;
if (
this.settings.onedrive.refreshToken !== "" &&
current >= this.settings.onedrive.credentialsShouldBeDeletedAtTime
) {
onedriveExpired = true;
this.settings.onedrive = cloneDeep(DEFAULT_ONEDRIVE_CONFIG);
needSave = true;
}
// save back
if (needSave) {
await this.saveSettings();
}
// send notice
if (dropboxExpired && onedriveExpired) {
new Notice(
`${this.manifest.name}: You haven't manually auth Dropbox and OneDrive for a while, you need to re-auth them again.`,
6000
);
} else if (dropboxExpired) {
new Notice(
`${this.manifest.name}: You haven't manually auth Dropbox for a while, you need to re-auth it again.`,
6000
);
} else if (onedriveExpired) {
new Notice(
`${this.manifest.name}: You haven't manually auth OneDrive for a while, you need to re-auth it again.`,
6000
);
}
}
async checkIfVaultIDAssigned() {
if (
this.settings.vaultRandomID === undefined ||
this.settings.vaultRandomID === ""
) {
this.settings.vaultRandomID = nanoid();
await this.saveSettings();
}
}
async prepareDB() {
this.db = await prepareDBs();
this.db = await prepareDBs(this.settings.vaultRandomID);
}
destroyDBs() {
/* destroyDBs(this.db); */
}
}
export class PasswordModal extends Modal {
plugin: SaveRemotePlugin;
newPassword: string;
constructor(app: App, plugin: SaveRemotePlugin, 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: "This password allows you encrypt your files before sending to remote services.",
});
contentEl.createEl("p", { text: "Empty means no password." });
contentEl.createEl("p", {
text: "Attention 1/4: The password setting itself is stored in PLAIN TEXT LOCALLY (because the plugin needs to use the password to encrypt the files) (and the password would not be sent to remote by this plugin).",
});
contentEl.createEl("p", {
text: "Attention 2/4: The file contents are encrypted using openssl format. BUT, some metadata such as file sizes and directory structures are not encrypted or can be easily guessed.",
});
contentEl.createEl("p", {
text: "Attention 3/4: If you change the password. You should make sure the remote service (s3/webdav/...) IS EMPTY, or REMOTE FILES WERE ENCRYPTED BY THAT NEW PASSWORD. OTHERWISE SOMETHING BAD WOULD HAPPEN!",
});
contentEl.createEl("p", {
text: "Attention 4/4: 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("Cancel (password not changed.)");
button.onClick(() => {
this.close();
});
});
}
onClose() {
let { contentEl } = this;
contentEl.empty();
}
}
class SaveRemoteSettingTab extends PluginSettingTab {
plugin: SaveRemotePlugin;
constructor(app: App, plugin: SaveRemotePlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
let { containerEl } = this;
containerEl.empty();
containerEl.createEl("h1", { text: "Save Remote" });
containerEl.createEl("h2", { text: "S3" });
new Setting(containerEl)
.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(containerEl)
.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(containerEl)
.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(containerEl)
.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(containerEl)
.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(containerEl)
.setName("check connectivity")
.setDesc("check connectivity")
.addButton(async (button) => {
button.setButtonText("Check");
button.onClick(async () => {
new Notice("Checking...");
const s3Client = getS3Client(this.plugin.settings.s3);
const res = await checkS3Connectivity(
s3Client,
this.plugin.settings.s3
);
if (res) {
new Notice("Great! The bucket can be accessed.");
} else {
new Notice("The S3 bucket cannot be reached.");
}
});
});
containerEl.createEl("h2", { text: "General" });
let newPassword = `${this.plugin.settings.password}`;
new Setting(containerEl)
.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();
});
});
containerEl.createEl("h2", { text: "Debug" });
const syncPlanDiv = containerEl.createEl("div");
syncPlanDiv.createEl("p", {
text: "Sync plans are created every time after you trigger sync and before the actual sync.",
});
syncPlanDiv.createEl("p", {
text: "They are useful to know what would actually happen in those sync.",
});
new Setting(containerEl)
.setName("export sync plans")
.setDesc("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(containerEl)
.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 = containerEl.createEl("div");
syncMappingDiv.createEl("p", {
text: "Sync mappings history stores the actual LOCAL last modified time of the REMOTE objects.",
});
syncMappingDiv.createEl("p", {
text: "If the sync mappings history are deleted, unnecessary data exchanges may occur in next-time syncing, because whether a remote object and local object with same name are equivalent or not could not be determined correctly by comparing last modified times.",
});
new Setting(containerEl)
.setName("delete sync mappings history in db")
.setDesc("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");
});
});
async setCurrSyncMsg(
i: number,
totalCount: number,
pathName: string,
decision: string
) {
const msg = `syncing progress=${i}/${totalCount},decision=${decision},path=${pathName}`;
this.currSyncMsg = msg;
}
}
+86 -8
View File
@@ -1,10 +1,11 @@
import { Vault } from "obsidian";
import * as path from "path";
import { base32 } from "rfc4648";
import { base32, base64url } from "rfc4648";
import XRegExp from "xregexp";
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "ftp";
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
/**
* If any part of the file starts with '.' or '_' then it's a hidden file.
@@ -15,7 +16,7 @@ export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "ftp";
export const isHiddenPath = (item: string, loose: boolean = true) => {
const k = path.posix.normalize(item); // TODO: only unix path now
const k2 = k.split("/"); // TODO: only unix path now
// console.log(k2)
// log.info(k2)
for (const singlePart of k2) {
if (singlePart === "." || singlePart === ".." || singlePart === "") {
continue;
@@ -47,20 +48,23 @@ export const getFolderLevels = (x: string) => {
const y1 = x.split("/");
let i = 0;
for (let index = 0; index + 1 < y1.length; index++) {
res.push(y1.slice(0, index + 1).join("/"));
const k = y1.slice(0, index + 1).join("/");
if (k !== "" && k !== "/") {
res.push(k);
}
}
return res;
};
export const mkdirpInVault = async (thePath: string, vault: Vault) => {
// console.log(thePath);
// log.info(thePath);
const foldersToBuild = getFolderLevels(thePath);
// console.log(foldersToBuild);
// log.info(foldersToBuild);
for (const folder of foldersToBuild) {
const r = await vault.adapter.exists(folder);
// console.log(r);
// log.info(r);
if (!r) {
console.log(`mkdir ${folder}`);
log.info(`mkdir ${folder}`);
await vault.adapter.mkdir(folder);
}
}
@@ -113,6 +117,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.
@@ -130,3 +142,69 @@ export const isVaildText = (a: string) => {
a
);
};
/**
* If input is already a folder, returns it as is;
* And if input is a file, returns its direname.
* @param a
* @returns
*/
export const getPathFolder = (a: string) => {
if (a.endsWith("/")) {
return a;
}
const b = path.posix.dirname(a);
return b.endsWith("/") ? b : `${b}/`;
};
/**
* https://stackoverflow.com/questions/54511144
* @param a
* @param delimiter
* @returns
*/
export const setToString = (a: Set<string>, delimiter: string = ",") => {
return [...a].join(delimiter);
};
export const extractSvgSub = (x: string, subEl: string = "rect") => {
const parser = new window.DOMParser();
const dom = parser.parseFromString(x, "image/svg+xml");
const svg = dom.querySelector("svg");
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);
};
/**
* https://stackoverflow.com/questions/958908
* @param x
* @returns
*/
export const reverseString = (x: string) => {
return [...x].reverse().join("");
};
+284
View File
@@ -0,0 +1,284 @@
import { Vault } from "obsidian";
import type {
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 origLog from "loglevel";
const log = origLog.getLogger("rs-default");
export class RemoteClient {
readonly serviceType: SUPPORTED_SERVICES_TYPE;
readonly s3Client?: s3.S3Client;
readonly s3Config?: S3Config;
readonly webdavClient?: webdav.WrappedWebdavClient;
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>
) {
this.serviceType = serviceType;
// the client may modify the config inplace,
// so we use a ref not copy of config here
if (serviceType === "s3") {
this.s3Config = s3Config;
this.s3Client = s3.getS3Client(this.s3Config);
} else if (serviceType === "webdav") {
if (vaultName === undefined) {
throw Error("remember to provide vault name while init webdav client");
}
this.webdavConfig = webdavConfig;
this.webdavClient = webdav.getWebdavClient(this.webdavConfig, vaultName);
} else if (serviceType === "dropbox") {
if (vaultName === undefined || saveUpdatedConfigFunc === undefined) {
throw Error(
"remember to provide vault name and callback while init dropbox client"
);
}
this.dropboxConfig = dropboxConfig;
this.dropboxClient = dropbox.getDropboxClient(
this.dropboxConfig,
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}`);
}
}
getRemoteMeta = async (fileOrFolderPath: string) => {
if (this.serviceType === "s3") {
return await s3.getRemoteMeta(
this.s3Client,
this.s3Config,
fileOrFolderPath
);
} else if (this.serviceType === "webdav") {
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}`);
}
};
uploadToRemote = async (
fileOrFolderPath: string,
vault: Vault,
isRecursively: boolean = false,
password: string = "",
remoteEncryptedKey: string = "",
foldersCreatedBefore: Set<string> | undefined = undefined
) => {
if (this.serviceType === "s3") {
return await s3.uploadToRemote(
this.s3Client,
this.s3Config,
fileOrFolderPath,
vault,
isRecursively,
password,
remoteEncryptedKey
);
} else if (this.serviceType === "webdav") {
return await webdav.uploadToRemote(
this.webdavClient,
fileOrFolderPath,
vault,
isRecursively,
password,
remoteEncryptedKey
);
} else if (this.serviceType === "dropbox") {
return await dropbox.uploadToRemote(
this.dropboxClient,
fileOrFolderPath,
vault,
isRecursively,
password,
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}`);
}
};
listFromRemote = async (prefix?: string) => {
if (this.serviceType === "s3") {
return await s3.listFromRemote(this.s3Client, this.s3Config, prefix);
} else if (this.serviceType === "webdav") {
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}`);
}
};
downloadFromRemote = async (
fileOrFolderPath: string,
vault: Vault,
mtime: number,
password: string = "",
remoteEncryptedKey: string = ""
) => {
if (this.serviceType === "s3") {
return await s3.downloadFromRemote(
this.s3Client,
this.s3Config,
fileOrFolderPath,
vault,
mtime,
password,
remoteEncryptedKey
);
} else if (this.serviceType === "webdav") {
return await webdav.downloadFromRemote(
this.webdavClient,
fileOrFolderPath,
vault,
mtime,
password,
remoteEncryptedKey
);
} else if (this.serviceType === "dropbox") {
return await dropbox.downloadFromRemote(
this.dropboxClient,
fileOrFolderPath,
vault,
mtime,
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}`);
}
};
deleteFromRemote = async (
fileOrFolderPath: string,
password: string = "",
remoteEncryptedKey: string = ""
) => {
if (this.serviceType === "s3") {
return await s3.deleteFromRemote(
this.s3Client,
this.s3Config,
fileOrFolderPath,
password,
remoteEncryptedKey
);
} else if (this.serviceType === "webdav") {
return await webdav.deleteFromRemote(
this.webdavClient,
fileOrFolderPath,
password,
remoteEncryptedKey
);
} else if (this.serviceType === "dropbox") {
return await dropbox.deleteFromRemote(
this.dropboxClient,
fileOrFolderPath,
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}`);
}
};
checkConnectivity = async () => {
if (this.serviceType === "s3") {
return await s3.checkConnectivity(this.s3Client, this.s3Config);
} else if (this.serviceType === "webdav") {
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}`);
}
};
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}`);
}
};
revokeAuth = async () => {
if (this.serviceType === "dropbox") {
return await dropbox.revokeAuth(this.dropboxClient);
} else {
throw Error(`not supported service type ${this.serviceType}`);
}
};
}
+634
View File
@@ -0,0 +1,634 @@
import { Dropbox, DropboxAuth, files } from "dropbox";
import { Vault } from "obsidian";
import * as path from "path";
import {
DropboxConfig,
RemoteItem,
COMMAND_CALLBACK_DROPBOX,
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
} from "./baseTypes";
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
import { bufferToArrayBuffer, getFolderLevels, mkdirpInVault } from "./misc";
export { Dropbox } from "dropbox";
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
export const DEFAULT_DROPBOX_CONFIG: DropboxConfig = {
accessToken: "",
clientID: process.env.DEFAULT_DROPBOX_APP_KEY,
refreshToken: "",
accessTokenExpiresInSeconds: 0,
accessTokenExpiresAtTime: 0,
accountID: "",
username: "",
credentialsShouldBeDeletedAtTime: 0,
};
export const getDropboxPath = (fileOrFolderPath: string, vaultName: string) => {
let key = fileOrFolderPath;
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
// special
key = `/${vaultName}`;
}
if (!fileOrFolderPath.startsWith("/")) {
// then this is original path in Obsidian
key = `/${vaultName}/${fileOrFolderPath}`;
}
if (key.endsWith("/")) {
key = key.slice(0, key.length - 1);
}
return key;
};
const getNormPath = (fileOrFolderPath: string, vaultName: string) => {
if (
!(
fileOrFolderPath === `/${vaultName}` ||
fileOrFolderPath.startsWith(`/${vaultName}/`)
)
) {
throw Error(`"${fileOrFolderPath}" doesn't starts with "/${vaultName}/"`);
}
return fileOrFolderPath.slice(`/${vaultName}/`.length);
};
const fromDropboxItemToRemoteItem = (
x:
| files.FileMetadataReference
| files.FolderMetadataReference
| files.DeletedMetadataReference,
vaultName: string
): RemoteItem => {
let key = getNormPath(x.path_display, vaultName);
if (x[".tag"] === "folder" && !key.endsWith("/")) {
key = `${key}/`;
}
if (x[".tag"] === "folder") {
return {
key: key,
lastModified: undefined,
size: 0,
remoteType: "dropbox",
etag: `${x.id}\t`,
} as RemoteItem;
} else if (x[".tag"] === "file") {
return {
key: key,
lastModified: Date.parse(x.server_modified).valueOf(),
size: x.size,
remoteType: "dropbox",
etag: `${x.id}\t${x.content_hash}`,
} as RemoteItem;
} else if (x[".tag"] === "deleted") {
throw Error("do not support deleted tag");
}
};
/**
* Dropbox api doesn't return mtime for folders.
* This is a try to assign mtime by using files in folder.
* @param allFilesFolders
* @returns
*/
const fixLastModifiedTimeInplace = (allFilesFolders: RemoteItem[]) => {
if (allFilesFolders.length === 0) {
return;
}
// sort by longer to shorter
allFilesFolders.sort((a, b) => b.key.length - a.key.length);
// a "map" from dir to mtime
let potentialMTime = {} as Record<string, number>;
// first sort pass, from buttom to up
for (const item of allFilesFolders) {
if (item.key.endsWith("/")) {
// itself is a folder, and initially doesn't have mtime
if (item.lastModified === undefined && item.key in potentialMTime) {
// previously we gathered all sub info of this folder
item.lastModified = potentialMTime[item.key];
}
}
const parent = `${path.posix.dirname(item.key)}/`;
if (item.lastModified !== undefined) {
if (parent in potentialMTime) {
potentialMTime[parent] = Math.max(
potentialMTime[parent],
item.lastModified
);
} else {
potentialMTime[parent] = item.lastModified;
}
}
}
// second pass, from up to buttom.
// fill mtime by parent folder or Date.Now() if still not available.
// this is only possible if no any sub-folder-files recursively.
// we do not sort the array again, just iterate over it by reverse
// using good old for loop.
for (let i = allFilesFolders.length - 1; i >= 0; --i) {
const item = allFilesFolders[i];
if (!item.key.endsWith("/")) {
continue; // skip files
}
if (item.lastModified !== undefined) {
continue; // don't need to deal with it
}
const parent = `${path.posix.dirname(item.key)}/`;
if (parent in potentialMTime) {
item.lastModified = potentialMTime[parent];
} else {
item.lastModified = Date.now().valueOf();
potentialMTime[item.key] = item.lastModified;
}
}
return allFilesFolders;
};
////////////////////////////////////////////////////////////////////////////////
// Dropbox authorization using PKCE
// see https://dropbox.tech/developers/pkce--what-and-why-
////////////////////////////////////////////////////////////////////////////////
export const getAuthUrlAndVerifier = async (appKey: string) => {
const auth = new DropboxAuth({
clientId: appKey,
});
const authUrl = (
await auth.getAuthenticationUrl(
`obsidian://${COMMAND_CALLBACK_DROPBOX}`,
undefined,
"code",
"offline",
undefined,
"none",
true
)
).toString();
const verifier = auth.getCodeVerifier();
return {
authUrl: authUrl,
verifier: verifier,
};
};
export interface DropboxSuccessAuthRes {
access_token: string;
token_type: "bearer";
expires_in: string;
refresh_token?: string;
scope?: string;
uid?: string;
account_id?: string;
}
export const sendAuthReq = async (
appKey: string,
verifier: string,
authCode: string
) => {
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
method: "POST",
body: new URLSearchParams({
code: authCode,
grant_type: "authorization_code",
code_verifier: verifier,
client_id: appKey,
redirect_uri: `obsidian://${COMMAND_CALLBACK_DROPBOX}`,
}),
});
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
return resp2;
};
export const sendRefreshTokenReq = async (
appKey: string,
refreshToken: string
) => {
log.info("start auto getting refreshed Dropbox access token.");
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: appKey,
}),
});
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
log.info("finish auto getting refreshed Dropbox access token.");
return resp2;
};
export const setConfigBySuccessfullAuthInplace = async (
config: DropboxConfig,
authRes: DropboxSuccessAuthRes,
saveUpdatedConfigFunc: () => Promise<any> | undefined
) => {
log.info("start updating local info of Dropbox token");
config.accessToken = authRes.access_token;
config.accessTokenExpiresInSeconds = parseInt(authRes.expires_in);
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;
}
if (authRes.refresh_token !== undefined) {
config.accountID = authRes.account_id;
}
if (saveUpdatedConfigFunc !== undefined) {
await saveUpdatedConfigFunc();
}
log.info("finish updating local info of Dropbox token");
};
////////////////////////////////////////////////////////////////////////////////
// Other usual common methods
////////////////////////////////////////////////////////////////////////////////
export class WrappedDropboxClient {
dropboxConfig: DropboxConfig;
vaultName: string;
saveUpdatedConfigFunc: () => Promise<any>;
dropbox: Dropbox;
vaultFolderExists: boolean;
constructor(
dropboxConfig: DropboxConfig,
vaultName: string,
saveUpdatedConfigFunc: () => Promise<any>
) {
this.dropboxConfig = dropboxConfig;
this.vaultName = vaultName;
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
this.vaultFolderExists = false;
}
init = async () => {
// check token
if (
this.dropboxConfig.accessToken === "" ||
this.dropboxConfig.refreshToken === ""
) {
throw Error("The user has not manually auth yet.");
}
const currentTs = Date.now();
if (this.dropboxConfig.accessTokenExpiresAtTime > currentTs) {
this.dropbox = new Dropbox({
accessToken: this.dropboxConfig.accessToken,
});
} else {
if (this.dropboxConfig.refreshToken === "") {
throw Error(
"We need to automatically refresh token but none is stored."
);
}
const resp = await sendRefreshTokenReq(
this.dropboxConfig.clientID,
this.dropboxConfig.refreshToken
);
setConfigBySuccessfullAuthInplace(
this.dropboxConfig,
resp,
this.saveUpdatedConfigFunc
);
this.dropbox = new Dropbox({
accessToken: this.dropboxConfig.accessToken,
});
}
// check vault folder
// log.info(`checking remote has folder /${this.vaultName}`);
if (this.vaultFolderExists) {
// log.info(`already checked, /${this.vaultName} exist before`)
} else {
const res = await this.dropbox.filesListFolder({
path: "",
recursive: false,
});
for (const item of res.result.entries) {
if (item.path_display === `/${this.vaultName}`) {
this.vaultFolderExists = true;
break;
}
}
if (!this.vaultFolderExists) {
log.info(`remote does not have folder /${this.vaultName}`);
await this.dropbox.filesCreateFolderV2({
path: `/${this.vaultName}`,
});
log.info(`remote folder /${this.vaultName} created`);
this.vaultFolderExists = true;
} else {
// log.info(`remote folder /${this.vaultName} exists`);
}
}
return this.dropbox;
};
}
/**
* @param dropboxConfig
* @returns
*/
export const getDropboxClient = (
dropboxConfig: DropboxConfig,
vaultName: string,
saveUpdatedConfigFunc: () => Promise<any>
) => {
return new WrappedDropboxClient(
dropboxConfig,
vaultName,
saveUpdatedConfigFunc
);
};
export const getRemoteMeta = async (
client: WrappedDropboxClient,
fileOrFolderPath: string
) => {
await client.init();
if (fileOrFolderPath === "" || fileOrFolderPath === "/") {
// filesGetMetadata doesn't support root folder
// we instead try to list files
// if no error occurs, we ensemble a fake result.
const rsp = await client.dropbox.filesListFolder({
path: `/${client.vaultName}`,
recursive: false, // don't need to recursive here
});
if (rsp.status !== 200) {
throw Error(JSON.stringify(rsp));
}
return {
key: fileOrFolderPath,
lastModified: undefined,
size: 0,
remoteType: "dropbox",
etag: undefined,
} as RemoteItem;
}
const key = getDropboxPath(fileOrFolderPath, client.vaultName);
const rsp = await client.dropbox.filesGetMetadata({
path: key,
});
if (rsp.status !== 200) {
throw Error(JSON.stringify(rsp));
}
return fromDropboxItemToRemoteItem(rsp.result, client.vaultName);
};
export const uploadToRemote = async (
client: WrappedDropboxClient,
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 = getDropboxPath(uploadFile, client.vaultName);
const isFolder = fileOrFolderPath.endsWith("/");
if (isFolder && isRecursively) {
throw Error("upload function doesn't implement recursive function yet!");
} else if (isFolder && !isRecursively) {
// folder
if (password === "") {
// if not encrypted, mkdir a remote folder
if (foldersCreatedBefore?.has(uploadFile)) {
// created, pass
} else {
try {
await client.dropbox.filesCreateFolderV2({
path: uploadFile,
});
foldersCreatedBefore?.add(uploadFile);
} catch (err) {
if (err.status === 409) {
// pass
foldersCreatedBefore?.add(uploadFile);
} else {
throw err;
}
}
}
const res = await getRemoteMeta(client, uploadFile);
return res;
} else {
// if encrypted, upload a fake file with the encrypted file name
await client.dropbox.filesUpload({
path: uploadFile,
contents: "",
});
return await getRemoteMeta(client, uploadFile);
}
} else {
// file
// we ignore isRecursively parameter here
const localContent = await vault.adapter.readBinary(fileOrFolderPath);
let remoteContent = localContent;
if (password !== "") {
remoteContent = await encryptArrayBuffer(localContent, password);
}
// in dropbox, we don't need to create folders before uploading! cool!
// TODO: filesUploadSession for larger files (>=150 MB)
await client.dropbox.filesUpload({
path: uploadFile,
contents: remoteContent,
mode: {
".tag": "overwrite",
},
});
// we want to mark that parent folders are created
if (foldersCreatedBefore !== undefined) {
const dirs = getFolderLevels(uploadFile).map((x) =>
getDropboxPath(x, client.vaultName)
);
for (const dir of dirs) {
foldersCreatedBefore?.add(dir);
}
}
return await getRemoteMeta(client, uploadFile);
}
};
export const listFromRemote = async (
client: WrappedDropboxClient,
prefix?: string
) => {
if (prefix !== undefined) {
throw Error("prefix not supported (yet)");
}
await client.init();
let res = await client.dropbox.filesListFolder({
path: `/${client.vaultName}`,
recursive: true,
include_deleted: false,
limit: 1000,
});
if (res.status !== 200) {
throw Error(JSON.stringify(res));
}
// log.info(res);
const contents = res.result.entries;
const unifiedContents = contents
.filter((x) => x[".tag"] !== "deleted")
.filter((x) => x.path_display !== `/${client.vaultName}`)
.map((x) => fromDropboxItemToRemoteItem(x, client.vaultName));
while (res.result.has_more) {
res = await client.dropbox.filesListFolderContinue({
cursor: res.result.cursor,
});
if (res.status !== 200) {
throw Error(JSON.stringify(res));
}
const contents2 = res.result.entries;
const unifiedContents2 = contents2
.filter((x) => x[".tag"] !== "deleted")
.filter((x) => x.path_display !== `/${client.vaultName}`)
.map((x) => fromDropboxItemToRemoteItem(x, client.vaultName));
unifiedContents.push(...unifiedContents2);
}
fixLastModifiedTimeInplace(unifiedContents);
return {
Contents: unifiedContents,
};
};
const downloadFromRemoteRaw = async (
client: WrappedDropboxClient,
fileOrFolderPath: string
) => {
await client.init();
const key = getDropboxPath(fileOrFolderPath, client.vaultName);
const rsp = await client.dropbox.filesDownload({
path: key,
});
if ((rsp.result as any).fileBlob !== undefined) {
// we get a Blob
const content = (rsp.result as any).fileBlob as Blob;
return await content.arrayBuffer();
} else if ((rsp.result as any).fileBinary !== undefined) {
// we get a Buffer
const content = (rsp.result as any).fileBinary as Buffer;
return bufferToArrayBuffer(content);
} else {
throw Error(`unknown rsp from dropbox download: ${rsp}`);
}
};
export const downloadFromRemote = async (
client: WrappedDropboxClient,
fileOrFolderPath: string,
vault: Vault,
mtime: number,
password: string = "",
remoteEncryptedKey: string = ""
) => {
await client.init();
const isFolder = fileOrFolderPath.endsWith("/");
await mkdirpInVault(fileOrFolderPath, vault);
// the file is always local file
// we need to encrypt it
if (isFolder) {
// mkdirp locally is enough
// do nothing here
} else {
let downloadFile = fileOrFolderPath;
if (password !== "") {
downloadFile = remoteEncryptedKey;
}
downloadFile = getDropboxPath(downloadFile, 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: WrappedDropboxClient,
fileOrFolderPath: string,
password: string = "",
remoteEncryptedKey: string = ""
) => {
if (fileOrFolderPath === "/") {
return;
}
let remoteFileName = fileOrFolderPath;
if (password !== "") {
remoteFileName = remoteEncryptedKey;
}
remoteFileName = getDropboxPath(remoteFileName, client.vaultName);
await client.init();
try {
await client.dropbox.filesDeleteV2({
path: remoteFileName,
});
} catch (err) {
console.error("some error while deleting");
console.error(err);
}
};
export const checkConnectivity = async (client: WrappedDropboxClient) => {
try {
const results = await getRemoteMeta(client, "/");
if (results === undefined) {
return false;
}
return true;
} catch (err) {
console.error("dropbox connectivity error:");
console.error(err);
return false;
}
};
export const getUserDisplayName = async (client: WrappedDropboxClient) => {
await client.init();
const acct = await client.dropbox.usersGetCurrentAccount();
return acct.result.name.display_name;
};
export const revokeAuth = async (client: WrappedDropboxClient) => {
await client.init();
await client.dropbox.authTokenRevoke();
};
+752
View File
@@ -0,0 +1,752 @@
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";
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
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
// });
// log.info('authResponse')
// log.info(authResponse)
// return authResponse;
// Because of the CORS problem,
// we need to construct raw request using Obsidian request,
// instead of using msal
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/getting-started/graph-oauth?view=odsp-graph-online#code-flow
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);
// log.info(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);
// log.info(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
) => {
log.info("start updating local info of OneDrive token");
config.accessToken = authRes.access_token;
config.accessTokenExpiresAtTime =
Date.now() + authRes.expires_in - 5 * 60 * 1000;
config.accessTokenExpiresInSeconds = authRes.expires_in;
config.refreshToken = authRes.refresh_token;
// manually set it expired after 80 days;
config.credentialsShouldBeDeletedAtTime =
Date.now() + OAUTH2_FORCE_EXPIRE_MILLISECONDS;
if (saveUpdatedConfigFunc !== undefined) {
await saveUpdatedConfigFunc();
}
log.info("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;
// or the root is absolute path /Livefolders,
// e.g.: /Livefolders/应用/remotely-save/${vaultName}
const SECOND_COMMON_PREFIX_REGEX = /^\/Livefolders\/[^\/]+\/remotely-save\//g;
// another possibile prefix
const THIRD_COMMON_PREFIX_RAW = `/drive/items/`;
const fullPathOriginal = `${x.parentReference.path}/${x.name}`;
const matchFirstPrefixRes = fullPathOriginal.match(FIRST_COMMON_PREFIX_REGEX);
const matchSecondPrefixRes = fullPathOriginal.match(
SECOND_COMMON_PREFIX_REGEX
);
if (
matchFirstPrefixRes !== null &&
fullPathOriginal.startsWith(`${matchFirstPrefixRes[0]}${vaultName}`)
) {
const foundPrefix = `${matchFirstPrefixRes[0]}${vaultName}`;
key = fullPathOriginal.substring(foundPrefix.length + 1);
} else if (
matchSecondPrefixRes !== null &&
fullPathOriginal.startsWith(`${matchSecondPrefixRes[0]}${vaultName}`)
) {
const foundPrefix = `${matchSecondPrefixRes[0]}${vaultName}`;
key = fullPathOriginal.substring(foundPrefix.length + 1);
} else if (x.parentReference.path.startsWith(THIRD_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();
log.info("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
// log.info(`checking remote has folder /${this.vaultName}`);
if (this.vaultFolderExists) {
// log.info(`already checked, /${this.vaultName} exist before`)
} else {
const k = await this.client.api("/drive/special/approot/children").get();
// log.info(k);
this.vaultFolderExists =
(k.value as DriveItem[]).filter((x) => x.name === this.vaultName)
.length > 0;
if (!this.vaultFolderExists) {
log.info(`remote does not have folder /${this.vaultName}`);
await this.client.api("/drive/special/approot/children").post({
name: `${this.vaultName}`,
folder: {},
"@microsoft.graph.conflictBehavior": "replace",
});
log.info(`remote folder /${this.vaultName} created`);
this.vaultFolderExists = true;
} else {
// log.info(`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);
// log.info(`remotePath=${remotePath}`);
const rsp = await client.client
.api(remotePath)
.select("cTag,eTag,fileSystemInfo,folder,file,name,parentReference,size")
.get();
// log.info(rsp);
const driveItem = rsp as DriveItem;
const res = fromDriveItemToRemoteItem(driveItem, client.vaultName);
// log.info(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);
// log.info(`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
// log.info(
// `uploading ${range.minValue}-${range.maxValue} of ${fileOrFolderPath}`
// );
},
} as UploadEventHandlers,
} as LargeFileUploadTaskOptions
);
const uploadResult: UploadResult = await task.upload();
// log.info(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
// log.info(
// `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();
// log.info(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";
};
+64 -44
View File
@@ -1,38 +1,32 @@
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,
} 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 { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
export interface S3Config {
s3Endpoint: string;
s3Region: string;
s3AccessKeyID: string;
s3SecretAccessKey: string;
s3BucketName: string;
}
export { S3Client } from "@aws-sdk/client-s3";
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
export const DEFAULT_S3_CONFIG = {
s3Endpoint: "",
@@ -44,10 +38,37 @@ export const DEFAULT_S3_CONFIG = {
export type S3ObjectType = _Object;
const fromS3ObjectToRemoteItem = (x: S3ObjectType) => {
return {
key: x.Key,
lastModified: x.LastModified.valueOf(),
size: x.Size,
remoteType: "s3",
etag: x.ETag,
} as RemoteItem;
};
const fromS3HeadObjectToRemoteItem = (
key: string,
x: HeadObjectCommandOutput
) => {
return {
key: key,
lastModified: x.LastModified.valueOf(),
size: x.ContentLength,
remoteType: "s3",
etag: x.ETag,
} as RemoteItem;
};
export const getS3Client = (s3Config: S3Config) => {
let endpoint = s3Config.s3Endpoint;
if (!(endpoint.startsWith("http://") || endpoint.startsWith("https://"))) {
endpoint = `https://${endpoint}`;
}
const s3Client = new S3Client({
region: s3Config.s3Region,
endpoint: s3Config.s3Endpoint,
endpoint: endpoint,
credentials: {
accessKeyId: s3Config.s3AccessKeyID,
secretAccessKey: s3Config.s3SecretAccessKey,
@@ -61,12 +82,14 @@ export const getRemoteMeta = async (
s3Config: S3Config,
fileOrFolderPath: string
) => {
return await s3Client.send(
const res = await s3Client.send(
new HeadObjectCommand({
Bucket: s3Config.s3BucketName,
Key: fileOrFolderPath,
})
);
return fromS3HeadObjectToRemoteItem(fileOrFolderPath, res);
};
export const uploadToRemote = async (
@@ -130,7 +153,7 @@ export const uploadToRemote = async (
},
});
upload.on("httpUploadProgress", (progress) => {
// console.log(progress);
// log.info(progress);
});
await upload.done();
@@ -177,10 +200,7 @@ export const listFromRemote = async (
// ensemble fake rsp
return {
"$.metadata": {
httpStatusCode: 200,
},
Contents: contents,
Contents: contents.map((x) => fromS3ObjectToRemoteItem(x)),
};
};
@@ -195,12 +215,12 @@ const getObjectBodyToArrayBuffer = async (
b: Readable | ReadableStream | Blob
) => {
if (b instanceof Readable) {
const chunks: Uint8Array[] = [];
for await (let chunk of b) {
chunks.push(chunk);
}
const buf = Buffer.concat(chunks);
return bufferToArrayBuffer(buf);
return (await new Promise((resolve, reject) => {
const chunks: Uint8Array[] = [];
b.on("data", (chunk) => chunks.push(chunk));
b.on("error", reject);
b.on("end", () => resolve(bufferToArrayBuffer(Buffer.concat(chunks))));
})) as ArrayBuffer;
} else if (b instanceof ReadableStream) {
return await new Response(b, {}).arrayBuffer();
} else if (b instanceof Blob) {
@@ -210,7 +230,7 @@ const getObjectBodyToArrayBuffer = async (
}
};
export const downloadFromRemoteRaw = async (
const downloadFromRemoteRaw = async (
s3Client: S3Client,
s3Config: S3Config,
fileOrFolderPath: string
@@ -298,7 +318,7 @@ export const deleteFromRemote = async (
await s3Client.send(
new DeleteObjectCommand({
Bucket: s3Config.s3BucketName,
Key: element.Key,
Key: element.key,
})
);
});
@@ -316,7 +336,7 @@ export const deleteFromRemote = async (
* @param s3Config
* @returns
*/
export const checkS3Connectivity = async (
export const checkConnectivity = async (
s3Client: S3Client,
s3Config: S3Config
) => {
+347
View File
@@ -0,0 +1,347 @@
import { Buffer } from "buffer";
import { Vault } from "obsidian";
import type { FileStat, WebDAVClient } from "webdav/web";
import { AuthType, BufferLike, createClient } from "webdav/web";
import { Queue } from "@fyears/tsqueue";
import chunk from "lodash/chunk";
import flatten from "lodash/flatten";
import type { RemoteItem, WebdavConfig } from "./baseTypes";
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
import { bufferToArrayBuffer, getPathFolder, mkdirpInVault } from "./misc";
export type { WebDAVClient } from "webdav/web";
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
export const DEFAULT_WEBDAV_CONFIG = {
address: "",
username: "",
password: "",
authType: "basic",
manualRecursive: false,
} as WebdavConfig;
const getWebdavPath = (fileOrFolderPath: string, vaultName: string) => {
let key = fileOrFolderPath;
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
// special
key = `/${vaultName}/`;
}
if (!fileOrFolderPath.startsWith("/")) {
key = `/${vaultName}/${fileOrFolderPath}`;
}
return key;
};
const getNormPath = (fileOrFolderPath: string, vaultName: string) => {
if (
!(
fileOrFolderPath === `/${vaultName}` ||
fileOrFolderPath.startsWith(`/${vaultName}/`)
)
) {
throw Error(`"${fileOrFolderPath}" doesn't starts with "/${vaultName}/"`);
}
// if (fileOrFolderPath.startsWith("/")) {
// return fileOrFolderPath.slice(1);
// }
return fileOrFolderPath.slice(`/${vaultName}/`.length);
};
const fromWebdavItemToRemoteItem = (x: FileStat, vaultName: string) => {
let key = getNormPath(x.filename, vaultName);
if (x.type === "directory" && !key.endsWith("/")) {
key = `${key}/`;
}
return {
key: key,
lastModified: Date.parse(x.lastmod).valueOf(),
size: x.size,
remoteType: "webdav",
etag: x.etag || undefined,
} as RemoteItem;
};
export class WrappedWebdavClient {
webdavConfig: WebdavConfig;
vaultName: string;
client: WebDAVClient;
vaultFolderExists: boolean;
constructor(webdavConfig: WebdavConfig, vaultName: string) {
this.webdavConfig = webdavConfig;
this.vaultName = vaultName;
this.vaultFolderExists = false;
}
init = async () => {
// init client if not inited
if (this.client === undefined) {
if (
this.webdavConfig.username !== "" &&
this.webdavConfig.password !== ""
) {
this.client = createClient(this.webdavConfig.address, {
username: this.webdavConfig.username,
password: this.webdavConfig.password,
authType:
this.webdavConfig.authType === "digest"
? AuthType.Digest
: AuthType.Password,
});
} else {
log.info("no password");
this.client = createClient(this.webdavConfig.address);
}
}
// check vault folder
if (this.vaultFolderExists) {
// pass
} else {
const res = await this.client.exists(`/${this.vaultName}`);
if (res) {
// log.info("remote vault folder exits!");
this.vaultFolderExists = true;
} else {
log.info("remote vault folder not exists, creating");
await this.client.createDirectory(`/${this.vaultName}`);
log.info("remote vault folder created!");
this.vaultFolderExists = true;
}
}
};
}
export const getWebdavClient = (
webdavConfig: WebdavConfig,
vaultName: string
) => {
return new WrappedWebdavClient(webdavConfig, vaultName);
};
export const getRemoteMeta = async (
client: WrappedWebdavClient,
fileOrFolderPath: string
) => {
await client.init();
const remotePath = getWebdavPath(fileOrFolderPath, client.vaultName);
// log.info(`remotePath = ${remotePath}`);
const res = (await client.client.stat(remotePath, {
details: false,
})) as FileStat;
return fromWebdavItemToRemoteItem(res, client.vaultName);
};
export const uploadToRemote = async (
client: WrappedWebdavClient,
fileOrFolderPath: string,
vault: Vault,
isRecursively: boolean = false,
password: string = "",
remoteEncryptedKey: string = ""
) => {
await client.init();
let uploadFile = fileOrFolderPath;
if (password !== "") {
uploadFile = remoteEncryptedKey;
}
uploadFile = getWebdavPath(uploadFile, client.vaultName);
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
await client.client.createDirectory(uploadFile, {
recursive: true,
});
const res = await getRemoteMeta(client, uploadFile);
return res;
} else {
// if encrypted, upload a fake file with the encrypted file name
await client.client.putFileContents(uploadFile, "", {
overwrite: true,
onUploadProgress: (progress) => {
// log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
},
});
return await getRemoteMeta(client, uploadFile);
}
} else {
// file
// we ignore isRecursively parameter here
const localContent = await vault.adapter.readBinary(fileOrFolderPath);
let remoteContent = localContent;
if (password !== "") {
remoteContent = await encryptArrayBuffer(localContent, password);
}
// we need to create folders before uploading
const dir = getPathFolder(uploadFile);
if (dir !== "/" && dir !== "") {
await client.client.createDirectory(dir, { recursive: true });
}
await client.client.putFileContents(uploadFile, remoteContent, {
overwrite: true,
onUploadProgress: (progress) => {
log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
},
});
return await getRemoteMeta(client, uploadFile);
}
};
export const listFromRemote = async (
client: WrappedWebdavClient,
prefix?: string
) => {
if (prefix !== undefined) {
throw Error("prefix not supported");
}
await client.init();
let contents = [] as FileStat[];
if (client.webdavConfig.manualRecursive) {
// the remote doesn't support infinity propfind,
// we need to do a bfs here
const q = new Queue([`/${client.vaultName}`]);
const CHUNK_SIZE = 10;
while (q.length > 0) {
const itemsToFetch = [];
while (q.length > 0) {
itemsToFetch.push(q.pop());
}
const itemsToFetchChunks = chunk(itemsToFetch, CHUNK_SIZE);
// log.debug(itemsToFetchChunks);
const subContents = [] as FileStat[];
for (const singleChunk of itemsToFetchChunks) {
const r = singleChunk.map((x) => {
return client.client.getDirectoryContents(x, {
deep: false,
details: false /* no need for verbose details here */,
glob: "/**" /* avoid dot files by using glob */,
}) as Promise<FileStat[]>;
});
const r2 = flatten(await Promise.all(r));
subContents.push(...r2);
}
for (let i = 0; i < subContents.length; ++i) {
const f = subContents[i];
contents.push(f);
if (f.type === "directory") {
q.push(f.filename);
}
}
}
} else {
// the remote supports infinity propfind
contents = (await client.client.getDirectoryContents(
`/${client.vaultName}`,
{
deep: true,
details: false /* no need for verbose details here */,
glob: "/**" /* avoid dot files by using glob */,
}
)) as FileStat[];
}
return {
Contents: contents.map((x) =>
fromWebdavItemToRemoteItem(x, client.vaultName)
),
};
};
const downloadFromRemoteRaw = async (
client: WrappedWebdavClient,
fileOrFolderPath: string
) => {
await client.init();
const buff = (await client.client.getFileContents(
getWebdavPath(fileOrFolderPath, client.vaultName)
)) as BufferLike;
if (buff instanceof ArrayBuffer) {
return buff;
} else if (buff instanceof Buffer) {
return bufferToArrayBuffer(buff);
}
throw Error(`unexpected file content result with type ${typeof buff}`);
};
export const downloadFromRemote = async (
client: WrappedWebdavClient,
fileOrFolderPath: string,
vault: Vault,
mtime: number,
password: string = "",
remoteEncryptedKey: string = ""
) => {
await client.init();
const isFolder = fileOrFolderPath.endsWith("/");
await mkdirpInVault(fileOrFolderPath, vault);
// the file is always local file
// we need to encrypt it
if (isFolder) {
// mkdirp locally is enough
// do nothing here
} else {
let downloadFile = fileOrFolderPath;
if (password !== "") {
downloadFile = remoteEncryptedKey;
}
downloadFile = getWebdavPath(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: WrappedWebdavClient,
fileOrFolderPath: string,
password: string = "",
remoteEncryptedKey: string = ""
) => {
if (fileOrFolderPath === "/") {
return;
}
let remoteFileName = fileOrFolderPath;
if (password !== "") {
remoteFileName = remoteEncryptedKey;
}
remoteFileName = getWebdavPath(remoteFileName, client.vaultName);
await client.init();
try {
await client.client.deleteFile(remoteFileName);
// log.info(`delete ${remoteFileName} succeeded`);
} catch (err) {
console.error("some error while deleting");
log.info(err);
}
};
export const checkConnectivity = async (client: WrappedWebdavClient) => {
try {
await client.init();
const results = await getRemoteMeta(client, "/");
if (results === undefined) {
return false;
}
return true;
} catch (err) {
return false;
}
};
+1092
View File
File diff suppressed because it is too large Load Diff
+245 -151
View File
@@ -1,32 +1,23 @@
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
import { S3Client } from "@aws-sdk/client-s3";
import * as lf from "lovefield-ts/dist/es6/lf.js";
import {
clearDeleteRenameHistoryOfKey,
FileFolderHistoryRecord,
upsertSyncMetaMappingDataS3,
getSyncMetaMappingByRemoteKeyS3,
} from "./localdb";
import {
S3Config,
S3ObjectType,
uploadToRemote,
deleteFromRemote,
downloadFromRemote,
} from "./s3";
import {
mkdirpInVault,
SUPPORTED_SERVICES_TYPE,
isHiddenPath,
isVaildText,
} 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 {
clearDeleteRenameHistoryOfKeyAndVault,
getSyncMetaMappingByRemoteKeyAndVault,
upsertSyncMetaMappingDataByVault,
} from "./localdb";
import { isHiddenPath, isVaildText, mkdirpInVault } from "./misc";
import { RemoteClient } from "./remote";
import * as origLog from "loglevel";
const log = origLog.getLogger("rs-default");
export type SyncStatusType =
| "idle"
@@ -85,7 +76,7 @@ export interface PasswordCheckType {
}
export const isPasswordOk = async (
remote: S3ObjectType[],
remote: RemoteItem[],
password: string = ""
) => {
if (remote === undefined || remote.length === 0) {
@@ -95,9 +86,9 @@ export const isPasswordOk = async (
reason: "empty_remote",
} as PasswordCheckType;
}
const santyCheckKey = remote[0].Key;
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 {
@@ -108,6 +99,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)) {
@@ -143,44 +166,56 @@ export const isPasswordOk = async (
};
const ensembleMixedStates = async (
remote: S3ObjectType[],
remote: RemoteItem[],
local: TAbstractFile[],
deleteHistory: FileFolderHistoryRecord[],
db: lf.DatabaseConnection,
db: InternalDBs,
vaultRandomID: string,
remoteType: SUPPORTED_SERVICES_TYPE,
password: string = ""
) => {
const results = {} as Record<string, FileOrFolderMixedState>;
if (remote !== undefined) {
for (const entry of remote) {
const remoteEncryptedKey = entry.Key;
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 getSyncMetaMappingByRemoteKeyS3(
const backwardMapping = await getSyncMetaMappingByRemoteKeyAndVault(
remoteType,
db,
key,
entry.LastModified.valueOf(),
entry.ETag
entry.lastModified,
entry.etag,
vaultRandomID
);
let r = {} as FileOrFolderMixedState;
if (backwardMapping !== undefined) {
key = backwardMapping.local_key;
key = backwardMapping.localKey;
r = {
key: key,
exist_remote: true,
mtime_remote: backwardMapping.local_mtime,
size_remote: backwardMapping.local_size,
mtime_remote: backwardMapping.localMtime || entry.lastModified,
size_remote: backwardMapping.localSize || entry.size,
remote_encrypted_key: remoteEncryptedKey,
};
} else {
r = {
key: key,
exist_remote: true,
mtime_remote: entry.LastModified.valueOf(),
size_remote: entry.Size,
mtime_remote: entry.lastModified,
size_remote: entry.size,
remote_encrypted_key: remoteEncryptedKey,
};
}
@@ -240,11 +275,11 @@ const ensembleMixedStates = async (
for (const entry of deleteHistory) {
let key = entry.key;
if (entry.key_type === "folder") {
if (entry.keyType === "folder") {
if (!entry.key.endsWith("/")) {
key = `${entry.key}/`;
}
} else if (entry.key_type === "file") {
} else if (entry.keyType === "file") {
// pass
} else {
throw Error(`unexpected ${entry}`);
@@ -252,7 +287,7 @@ const ensembleMixedStates = async (
const r = {
key: key,
delete_time_local: entry.action_when,
delete_time_local: entry.actionWhen,
} as FileOrFolderMixedState;
if (isHiddenPath(key)) {
@@ -398,14 +433,20 @@ const getOperation = (
r.decision_branch = 11;
}
if (r.decision === "unknown") {
throw Error(`unknown decision for ${JSON.stringify(r)}`);
}
return r;
};
export const getSyncPlan = async (
remote: S3ObjectType[],
remote: RemoteItem[],
local: TAbstractFile[],
deleteHistory: FileFolderHistoryRecord[],
db: lf.DatabaseConnection,
db: InternalDBs,
vaultRandomID: string,
remoteType: SUPPORTED_SERVICES_TYPE,
password: string = ""
) => {
const mixedStates = await ensembleMixedStates(
@@ -413,6 +454,8 @@ export const getSyncPlan = async (
local,
deleteHistory,
db,
vaultRandomID,
remoteType,
password
);
for (const [key, val] of Object.entries(mixedStates)) {
@@ -420,120 +463,171 @@ export const getSyncPlan = async (
}
const plan = {
ts: Date.now(),
remoteType: "s3",
remoteType: remoteType,
mixedStates: mixedStates,
} as SyncPlanType;
return plan;
};
const dispatchOperationToActual = async (
key: string,
vaultRandomID: string,
state: FileOrFolderMixedState,
client: RemoteClient,
db: InternalDBs,
vault: Vault,
password: string = "",
foldersCreatedBefore: Set<string> | undefined = undefined
) => {
let remoteEncryptedKey = key;
if (password !== "") {
remoteEncryptedKey = state.remote_encrypted_key;
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
// the old version uses base32
// remoteEncryptedKey = await encryptStringToBase32(key, password);
// the new version users base64url
remoteEncryptedKey = await encryptStringToBase64url(key, password);
}
}
if (
state.decision === undefined ||
state.decision === "unknown" ||
state.decision === "undecided"
) {
throw Error(`unknown decision in ${JSON.stringify(state)}`);
} else if (state.decision === "skip") {
// do nothing
} else if (
client.serviceType === "onedrive" &&
state.size_local === 0 &&
!state.key.endsWith("/") &&
password === "" &&
(state.decision === "upload" || state.decision === "upload_clearhist")
) {
// TODO: it's ugly, any other way to deal with empty file for onedrive?
// do nothing, skip empty file without encryption
// if it's empty folder, or it's encrypted file/folder, it continues to be uploaded.
// this branch should be earlier than normal upload / upload_clearhist branches.
log.debug(`skip empty file ${state.key} uploading for OneDrive`);
} else if (state.decision === "download_clearhist") {
await client.downloadFromRemote(
state.key,
vault,
state.mtime_remote,
password,
remoteEncryptedKey
);
await clearDeleteRenameHistoryOfKeyAndVault(db, state.key, vaultRandomID);
} else if (state.decision === "upload_clearhist") {
const remoteObjMeta = await client.uploadToRemote(
state.key,
vault,
false,
password,
remoteEncryptedKey,
foldersCreatedBefore
);
await upsertSyncMetaMappingDataByVault(
client.serviceType,
db,
state.key,
state.mtime_local,
state.size_local,
state.key,
remoteObjMeta.lastModified,
remoteObjMeta.size,
remoteObjMeta.etag,
vaultRandomID
);
await clearDeleteRenameHistoryOfKeyAndVault(db, state.key, vaultRandomID);
} else if (state.decision === "download") {
await mkdirpInVault(state.key, vault);
await client.downloadFromRemote(
state.key,
vault,
state.mtime_remote,
password,
remoteEncryptedKey
);
} else if (state.decision === "delremote_clearhist") {
await client.deleteFromRemote(state.key, password, remoteEncryptedKey);
await clearDeleteRenameHistoryOfKeyAndVault(db, state.key, vaultRandomID);
} else if (state.decision === "upload") {
const remoteObjMeta = await client.uploadToRemote(
state.key,
vault,
false,
password,
remoteEncryptedKey,
foldersCreatedBefore
);
await upsertSyncMetaMappingDataByVault(
client.serviceType,
db,
state.key,
state.mtime_local,
state.size_local,
state.key,
remoteObjMeta.lastModified,
remoteObjMeta.size,
remoteObjMeta.etag,
vaultRandomID
);
} else if (state.decision === "clearhist") {
await clearDeleteRenameHistoryOfKeyAndVault(db, state.key, vaultRandomID);
} else {
throw Error("this should never happen!");
}
};
export const doActualSync = async (
s3Client: S3Client,
s3Config: S3Config,
db: lf.DatabaseConnection,
client: RemoteClient,
db: InternalDBs,
vaultRandomID: string,
vault: Vault,
syncPlan: SyncPlanType,
password: string = ""
password: string = "",
callbackSyncProcess?: any
) => {
const keyStates = syncPlan.mixedStates;
await Promise.all(
Object.entries(keyStates)
.sort((k, v) => -(k as string).length)
.map(async ([k, v]) => {
const key = k as string;
const state = v as FileOrFolderMixedState;
let remoteEncryptedKey = key;
if (password !== "") {
remoteEncryptedKey = state.remote_encrypted_key;
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
remoteEncryptedKey = await encryptStringToBase32(key, password);
}
}
if (
state.decision === undefined ||
state.decision === "unknown" ||
state.decision === "undecided"
) {
throw Error(`unknown decision in ${JSON.stringify(state)}`);
} else if (state.decision === "skip") {
// do nothing
} else if (state.decision === "download_clearhist") {
await downloadFromRemote(
s3Client,
s3Config,
state.key,
vault,
state.mtime_remote,
password,
remoteEncryptedKey
);
await clearDeleteRenameHistoryOfKey(db, state.key);
} else if (state.decision === "upload_clearhist") {
const remoteObjMeta = await uploadToRemote(
s3Client,
s3Config,
state.key,
vault,
false,
password,
remoteEncryptedKey
);
await upsertSyncMetaMappingDataS3(
db,
state.key,
state.mtime_local,
state.size_local,
state.key,
remoteObjMeta.LastModified.valueOf(),
remoteObjMeta.ContentLength,
remoteObjMeta.ETag
);
await clearDeleteRenameHistoryOfKey(db, state.key);
} else if (state.decision === "download") {
await mkdirpInVault(state.key, vault);
await downloadFromRemote(
s3Client,
s3Config,
state.key,
vault,
state.mtime_remote,
password,
remoteEncryptedKey
);
} else if (state.decision === "delremote_clearhist") {
await deleteFromRemote(
s3Client,
s3Config,
state.key,
password,
remoteEncryptedKey
);
await clearDeleteRenameHistoryOfKey(db, state.key);
} else if (state.decision === "upload") {
const remoteObjMeta = await uploadToRemote(
s3Client,
s3Config,
state.key,
vault,
false,
password,
remoteEncryptedKey
);
await upsertSyncMetaMappingDataS3(
db,
state.key,
state.mtime_local,
state.size_local,
state.key,
remoteObjMeta.LastModified.valueOf(),
remoteObjMeta.ContentLength,
remoteObjMeta.ETag
);
} else if (state.decision === "clearhist") {
await clearDeleteRenameHistoryOfKey(db, state.key);
} else {
throw Error("this should never happen!");
}
})
);
const foldersCreatedBefore = new Set<string>();
let i = 0;
const totalCount = Object.keys(keyStates).length || 0;
for (const [k, v] of Object.entries(keyStates).sort(
([k1, v1], [k2, v2]) => k2.length - k1.length
)) {
i += 1;
const k2 = k as string;
const v2 = v as FileOrFolderMixedState;
log.debug(`start syncing "${k2}" with plan ${JSON.stringify(v2)}`);
if (callbackSyncProcess !== undefined) {
await callbackSyncProcess(i, totalCount, k2, v2.decision);
}
await dispatchOperationToActual(
k2,
vaultRandomID,
v2,
client,
db,
vault,
password,
foldersCreatedBefore
);
log.info(`finished ${k2}`);
}
// await Promise.all(
// Object.entries(keyStates)
// .map(async ([k, v]) =>
// dispatchOperationToActual(
// k as string,
// v as FileOrFolderMixedState,
// client,
// db,
// vault,
// password,
// foldersCreatedBefore
// )
// )
// );
};
+54 -1
View File
@@ -1,5 +1,58 @@
/* set the styles */
.password_second_confirm {
.password-second-confirm {
font-weight: bold;
}
.password-disclaimer {
font-weight: bold;
}
.s3-disclaimer {
font-weight: bold;
}
.s3-hide {
display: none;
}
.dropbox-disclaimer {
font-weight: bold;
}
.dropbox-hide {
display: none;
}
.dropbox-auth-button-hide {
display: none;
}
.dropbox-revoke-auth-button-hide {
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;
}
.webdav-hide {
display: none;
}
.qrcode-img {
width: 350px;
height: 350px;
}
+35
View File
@@ -0,0 +1,35 @@
import * as chai from "chai";
import chaiAsPromised from "chai-as-promised";
import { RemotelySavePluginSettings } from "../src/baseTypes";
import { messyConfigToNormal, normalConfigToMessy } from "../src/configPersist";
chai.use(chaiAsPromised);
const expect = chai.expect;
const DEFAULT_SETTINGS: RemotelySavePluginSettings = {
s3: {
s3AccessKeyID: "acc",
} as any,
webdav: {
address: "addr",
} as any,
dropbox: {
username: "测试中文",
} as any,
onedrive: {
username: "test 🍎 emoji",
} as any,
password: "password",
serviceType: "s3",
currLogLevel: "info",
};
describe("Config Persist tests", () => {
it("should encrypt go back and forth conrrectly", async () => {
const k = DEFAULT_SETTINGS;
const k2 = normalConfigToMessy(k);
const k3 = messyConfigToNormal(k2);
expect(k3).to.deep.equal(k);
});
});
+12 -9
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,
@@ -60,14 +61,14 @@ describe("Encryption tests", () => {
);
// two command returns same result:
// cat ./sometext.txt | openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 10000 -base64 -pass pass:somepassword
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 10000 -base64 -pass pass:somepassword -in ./sometext.txt
// cat ./sometext.txt | openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -base64 -pass pass:somepassword
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -base64 -pass pass:somepassword -in ./sometext.txt
const opensslBase64Res =
"U2FsdGVkX1+DAvWG+rSR7MSa+yJav1zCE7SSXiBooqwI5Q+LMpIthpk/pXkLj+25";
"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 () => {
@@ -89,6 +90,8 @@ describe("Encryption tests", () => {
await fs.readFileSync(path.join(testFolder, testFileName + ".enc"))
);
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -pass pass:somepassword -in mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg -out mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg.enc
expect(Buffer.from(enc).equals(Buffer.from(opensslArrBuf))).to.be.true;
});
+59 -3
View File
@@ -1,7 +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", () => {
@@ -69,6 +67,28 @@ describe("Misc: get folder levels", () => {
const res3 = ["xxx", "xxx/yyy", "xxx/yyy/zzz"];
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
});
it("should treat path starting with / correctly", () => {
const item = "/xxx/yyy/zzz.md";
const res = ["/xxx", "/xxx/yyy"];
expect(misc.getFolderLevels(item)).to.deep.equal(res);
const item2 = "/xxx/yyy/zzz";
const res2 = ["/xxx", "/xxx/yyy"];
expect(misc.getFolderLevels(item2)).to.deep.equal(res2);
const item3 = "/xxx/yyy/zzz/";
const res3 = ["/xxx", "/xxx/yyy", "/xxx/yyy/zzz"];
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
const item4 = "/xxx";
const res4 = [] as string[];
expect(misc.getFolderLevels(item4)).to.deep.equal(res4);
const item5 = "/";
const res5 = [] as string[];
expect(misc.getFolderLevels(item5)).to.deep.equal(res5);
});
});
describe("Misc: vaild file name tests", () => {
@@ -89,3 +109,39 @@ describe("Misc: vaild file name tests", () => {
expect(x).to.be.true;
});
});
describe("Misc: get dirname", () => {
it("should return itself for folder", async () => {
const x = misc.getPathFolder("ssss/");
// console.log(x)
expect(x).to.equal("ssss/");
});
it("should return folder for file", async () => {
const x = misc.getPathFolder("sss/yyy");
// console.log(x)
expect(x).to.equal("sss/");
});
it("should treat / specially", async () => {
const x = misc.getPathFolder("/");
expect(x).to.equal("/");
const y = misc.getPathFolder("/abc");
expect(y).to.equal("/");
});
});
describe("Misc: extract svg", () => {
beforeEach(function () {
const fakeBrowser = new JSDOM("");
global.window = fakeBrowser.window as any;
});
it("should extract rect from svg correctly", () => {
const x = "<svg><rect/><g/></svg>";
const y = misc.extractSvgSub(x);
// console.log(x)
expect(y).to.equal("<rect/><g/>");
});
});
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cf67abd791f1e230bce57dd47150f262424247cbefb8be16efcd57d13955576a
oid sha256:7687eaf60ed93f754b107dc86a437aa97fad7bedd40432171b08c22cded7b559
size 1141568
+1
View File
@@ -11,6 +11,7 @@
// "allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"importHelpers": true,
"isolatedModules": true,
"lib": ["dom", "es5", "scripthost", "es2015"]
},
"include": ["**/*.ts"]
+1 -1
View File
@@ -1,3 +1,3 @@
{
"0.0.14": "0.12.15"
"0.2.13": "0.12.15"
}
+16 -1
View File
@@ -1,7 +1,12 @@
require("dotenv").config();
const path = require("path");
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",
target: "web",
@@ -11,6 +16,11 @@ module.exports = {
libraryTarget: "commonjs",
},
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
new webpack.ProvidePlugin({
@@ -27,6 +37,10 @@ module.exports = {
use: "ts-loader",
exclude: /node_modules/,
},
{
test: /\.svg?$/,
type: "asset/source",
},
],
},
resolve: {
@@ -37,7 +51,8 @@ module.exports = {
// buffer: require.resolve("buffer/"),
// console: require.resolve("console-browserify"),
// constants: require.resolve("constants-browserify"),
// crypto: require.resolve("crypto-browserify"),
crypto: require.resolve("crypto-browserify"),
// crypto: false,
// domain: require.resolve("domain-browser"),
// events: require.resolve("events"),
// http: require.resolve("stream-http"),