Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
553a1f7bed | ||
|
|
da837ec7af | ||
|
|
d250e676a2 | ||
|
|
7b78f19ca1 | ||
|
|
f7082616aa | ||
|
|
d9f926e376 | ||
|
|
13a2d6f53a | ||
|
|
5092c59ed3 | ||
|
|
fc9ab2e63f | ||
|
|
91af2c849f | ||
|
|
fafe74526d | ||
|
|
320f91f1a5 | ||
|
|
ea7c9ae203 | ||
|
|
2bebc7226d | ||
|
|
c9b86fc42d | ||
|
|
5d11801c28 | ||
|
|
1f8754633f | ||
|
|
6cdbc5b32e | ||
|
|
a78a2a88cf | ||
|
|
4035218706 | ||
|
|
b0cb1248a5 | ||
|
|
f936704a6a | ||
|
|
04f3a0c97c | ||
|
|
c637038d95 | ||
|
|
e7c24b55a3 | ||
|
|
9a66e3181d | ||
|
|
510755d7f0 | ||
|
|
27fe428fe4 | ||
|
|
ffabaacaf1 | ||
|
|
6790bb01d3 | ||
|
|
131f3f70e1 | ||
|
|
b551e0c1b5 | ||
|
|
c65c0aa258 | ||
|
|
1f0030b514 | ||
|
|
07896f92a3 | ||
|
|
9eddfb02d6 | ||
|
|
f0dd3bb5ef | ||
|
|
b91c0e694c | ||
|
|
84b03d2c2b | ||
|
|
313eff6a14 | ||
|
|
8671ba4660 | ||
|
|
3b195f33c8 | ||
|
|
65e18aa4b7 | ||
|
|
2cedef8168 | ||
|
|
22bc74f4d7 | ||
|
|
277793ccf4 | ||
|
|
9a1b11b811 | ||
|
|
3d6746ed99 | ||
|
|
2a797f8c40 | ||
|
|
0f7b13ec27 | ||
|
|
abd124b155 | ||
|
|
2a42abb9cc | ||
|
|
e9bb61ced4 | ||
|
|
5b703334d7 | ||
|
|
4639b93cfa | ||
|
|
92f12cd5f4 | ||
|
|
72417cd075 | ||
|
|
f8a930eb4e | ||
|
|
6832c9f649 | ||
|
|
34d1b01c7b | ||
|
|
f26ee8bbc5 | ||
|
|
414a3f5e4a | ||
|
|
02e5f9b5a0 | ||
|
|
d53569fe71 | ||
|
|
4865a797aa | ||
|
|
5a5898f315 | ||
|
|
d5ba0a0e0d | ||
|
|
dde7d9a732 | ||
|
|
1bc6ba4b01 | ||
|
|
792703f8b6 | ||
|
|
6552fd32d1 | ||
|
|
f3e6bc2fdd | ||
|
|
63d52df378 | ||
|
|
8833532370 | ||
|
|
8cca96e138 | ||
|
|
804afabdeb |
@@ -0,0 +1,3 @@
|
|||||||
|
DROPBOX_APP_KEY=
|
||||||
|
ONEDRIVE_CLIENT_ID=
|
||||||
|
ONEDRIVE_AUTHORITY=https://
|
||||||
@@ -15,6 +15,11 @@ jobs:
|
|||||||
|
|
||||||
environment: env-for-buildci
|
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:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [16.x]
|
node-version: [16.x]
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
main.js
|
main.js
|
||||||
|
data.json
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# Remotely Save
|
# Remotely Save
|
||||||
|
|
||||||
This is yet another unofficial sync plugin for Obsidian.
|
This is yet another unofficial sync plugin for Obsidian. If you like it or find it useful, please consider give it a [star ](https://github.com/fyears/remotely-save) on Github.
|
||||||
|
|
||||||
|
[](https://github.com/fyears/remotely-save/actions/workflows/auto-build.yml)
|
||||||
|
|
||||||
|
[](https://github.com/fyears/remotely-save/releases)
|
||||||
|
|
||||||
|
[](https://github.com/fyears/remotely-save/releases)
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
@@ -8,23 +14,24 @@ This is yet another unofficial sync plugin for Obsidian.
|
|||||||
|
|
||||||
## !!!Caution!!!
|
## !!!Caution!!!
|
||||||
|
|
||||||
As of Dec 2021, the plugin is considered in BETA stage. **DO NOT USE IT for any serious vaults.** **ALWAYS, ALWAYS, backup your vault before using this plugin.**
|
As of Jan 2022, the plugin is considered in BETA stage. **DO NOT USE IT for any serious vaults.** **ALWAYS, ALWAYS, backup your vault before using this plugin.**
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Supports:
|
- Supports:
|
||||||
- Amazon S3 or S3-compatible
|
- Amazon S3 or S3-compatible
|
||||||
- Dropbox
|
- Dropbox
|
||||||
|
- OneDrive for personal
|
||||||
- Webdav
|
- Webdav
|
||||||
- **Obsidiain Mobile supported.** Vaults can be synced across mobile and desktop devices with the cloud service as the "broker".
|
- **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.
|
- **[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).**
|
- **[Minimal Intrusive](./docs/minimal_intrusive_design.md).**
|
||||||
- **Fully open source under [Apache-2.0 License](./LICENSE).**
|
- **Fully open source under [Apache-2.0 License](./LICENSE).**
|
||||||
- **[Sync Algorithm open](./docs/sync_algorithm.md) for discussion.**
|
- **[Sync Algorithm open](./docs/sync_algorithm.md) for discussion.**
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- **Users have to trigger the sync manually.** This design is intentional because the plugin is in beta, and it's better for users to be exactly aware of the running of this plugin.
|
|
||||||
- **"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.
|
- **"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.
|
- **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.
|
- **Cloud services cost you money.** Always be aware of the costs and pricing.
|
||||||
@@ -42,9 +49,10 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
|
|||||||
|
|
||||||
## Download and Install
|
## Download and Install
|
||||||
|
|
||||||
- Option #1: You can search, download, and install the plugin in official "community plugin list".
|
- 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: [](https://github.com/fyears/remotely-save/actions/workflows/auto-build.yml) Every artifacts are placed in the "Summary" under every successful builds.
|
- 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: Besides manually downloading the files, you can also use [Obsidian42 - BRAT](https://github.com/TfTHacker/obsidian42-brat) to install this plugin.
|
- Option #3: [](https://github.com/fyears/remotely-save/releases) Manually download assets (`main.js`, `manifest.json`, `styles.css`) from the latest release.
|
||||||
|
- Option #4: [](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
|
## Usage
|
||||||
|
|
||||||
@@ -55,38 +63,42 @@ Additionally, the plugin author may occasionally visit Obsidian official forum a
|
|||||||
- Download and enable this plugin.
|
- Download and enable this plugin.
|
||||||
- Enter your information to the settings of 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.
|
- 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 "switch" icon on the ribbon (the left sidebar), **every time** you want to sync your vault between local and remote. (No "auto sync" yet.)
|
- 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.
|
- **Be patient while syncing.** Especially in the first-time sync.
|
||||||
|
|
||||||
### Dropbox
|
### Dropbox
|
||||||
|
|
||||||
- **This plugin's function for Dropbox is not as mature as functions for S3.**
|
- **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.
|
- **This plugin is NOT an official Dropbox product.** The plugin just uses Dropbox's public API.
|
||||||
- After the authorization, the plugin can read your name and email (which cannot be unselected on Dropbox api), and read and write files in your Dropbox's `/Apps/remotely-save/${vaultName}` folder.
|
- After the authorization, the plugin can read your name and email (which cannot be unselected on Dropbox api), and read and write files in your Dropbox's `/Apps/remotely-save` folder.
|
||||||
- If you decide to authorize this plugin to connect to Dropbox, please go to plugin's settings, and choose Dropbox then follow the instructions.
|
- 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**.
|
- 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
|
||||||
|
|
||||||
- **webdav support is considered experimental.**
|
- **webdav support is considered experimental.**
|
||||||
- Currently only supports BASIC authorization method.
|
- 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.
|
- Currently webdav server has to be enabled CORS for requests from `app://obsidian.md` and `capacitor://localhost` and `http://localhost`, **AND** all webdav HTTP methods, **AND** all webdav headers. These are required, because Obsidian mobile works like a browser and mobile plugins are limited by CORS policies.
|
||||||
- Popular software NextCloud and OwnCloud do **NOT** enable CORS by default. If you are using any of them, you should evaluate the risk, and find a way to enable CORS, before using this plugin.
|
- Popular software NextCloud, OwnCloud, `rclone serve webdav` do **NOT** enable CORS by default. If you are using any of them, you should evaluate the risk, and find a way to enable CORS, before using this plugin.
|
||||||
- The plugin is tested successfully under python package [`wsgidav` (version 4.0)](https://github.com/mar10/wsgidav). See [this issue](https://github.com/mar10/wsgidav/issues/239) for some details.
|
- 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.
|
- 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**.
|
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
||||||
|
|
||||||
## Import And Export Plugin Settings By QR Code
|
## Scheduled Auto Sync
|
||||||
|
|
||||||
It's often verbose / inconvenient to input credentials on mobile. (On different desktop computers, it's trivial to copy and paste `data.json` across different devices.)
|
- 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.
|
||||||
|
|
||||||
So this plugin has a helper function to export settings as a QR code, then you could use mobile device's camera to import the settings.
|
## Bonus: Import And Export Not-Oauth2 Plugin Settings By QR Code
|
||||||
|
|
||||||
Steps:
|
See [here](./docs/import_export_some_settings.md) for more details.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a9bf1cc2403c3a32e7a6f5d5829bb065573922dc050ae2fdb8b3d34b4c87ecf5
|
||||||
|
size 8296
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6990a598379c457442345e50c3f056c4598a121523bc10980835c08fc7447a54
|
||||||
|
size 10119
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:432198e63fa9676541a41436917e025d1ea6af3772139c1dea450fcc0155cfd0
|
||||||
|
size 13054
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fe3b5b8bf9c32e57c0af366c455b11ae68eb9fb8a32f371c9c445b3e7e89cc02
|
||||||
|
size 1120
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f68df7bae94eb4c4cddfa6af47d7819d01a6482d4f6afe6ef3236f72f9adf15f
|
||||||
|
size 1523
|
||||||
@@ -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/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:daedd262170200cb5e8cc288898b40892771b3962cf2bc17dc443f1c82a4aa94
|
||||||
|
size 12886
|
||||||
+10
-5
@@ -15,15 +15,20 @@ The encryption algorithm is delibrately designed to be aligned with openssl form
|
|||||||
openssl enc -d -p -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:somepassword -in ./sometext.txt.enc -out ./sometext.txt
|
openssl enc -d -p -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:somepassword -in ./sometext.txt.enc -out ./sometext.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
3. The file/directory path strings, are encrypted using openssl in binary mode and then `base32` is applied.
|
3. The file/directory path strings, are encrypted using openssl in binary mode and then `base64url without padding` is applied.
|
||||||
Assuming the file path is `a-folder-文件夹/a-file-文件.md`, then the following commands are equivilent:
|
Assuming the file path is `a-folder-文件夹/a-file-文件.md`, then the following commands are equivilent:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# pure string encryption then base32
|
# prepare the functions
|
||||||
echo -n 'a-folder-文件夹/a-file-文件.md' | openssl enc -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword | base32 -w 0
|
# https://unix.stackexchange.com/questions/628842
|
||||||
|
base64url::encode () { base64 -w0 | tr '+/' '-_' | tr -d '='; }
|
||||||
|
base64url::decode () { awk '{ if (length($0) % 4 == 3) print $0"="; else if (length($0) % 4 == 2) print $0"=="; else print $0; }' | tr -- '-_' '+/' | base64 -d; }
|
||||||
|
|
||||||
# pure string base32 then decryption
|
# pure string encryption then base32
|
||||||
echo -n 'KNQWY5DFMRPV7UHRWVYFSHE2XVVVZCFN65SR7ETEKO5L6EYGXCVEPT4A2LVTW4W2ZHXWF3K22SVA562CCZ6SALARXJY6AAXXHLK5UOA=' | base32 -d -w 00 | openssl enc -d -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword
|
echo -n 'a-folder-文件夹/a-file-文件.md' | openssl enc -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword | base64url::encode
|
||||||
|
|
||||||
|
# pure string base64url then decryption
|
||||||
|
echo -n 'U2FsdGVkX19tNkdFL5rZeHxbe7FL-Pp5mkZJkDNFJWFT6lldZlfa57j0C_cKn0I3PZ9YDvOkyoKqfF6lbn0_yg' | base64url::decode | openssl enc -d -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword
|
||||||
```
|
```
|
||||||
|
|
||||||
4. The directory is considered as special "0-byte" object on remote s3. So this meta infomation may be easily guessed if some third party can access the remote bucket.
|
4. 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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -9,9 +9,13 @@ if you want to view the source, please visit the github repository of this plugi
|
|||||||
*/
|
*/
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
console.log(`esbuild version = ${esbuild.version}`);
|
||||||
|
|
||||||
const prod = process.argv[2] === "production";
|
const prod = process.argv[2] === "production";
|
||||||
|
|
||||||
const DEFAULT_DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY || "";
|
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
|
esbuild
|
||||||
.build({
|
.build({
|
||||||
@@ -40,6 +44,8 @@ esbuild
|
|||||||
outfile: "main.js",
|
outfile: "main.js",
|
||||||
define: {
|
define: {
|
||||||
"process.env.DEFAULT_DROPBOX_APP_KEY": `"${DEFAULT_DROPBOX_APP_KEY}"`,
|
"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));
|
.catch(() => process.exit(1));
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "remotely-save",
|
"id": "remotely-save",
|
||||||
"name": "Remotely Save",
|
"name": "Remotely Save",
|
||||||
"version": "0.2.4",
|
"version": "0.2.14",
|
||||||
"minAppVersion": "0.12.15",
|
"minAppVersion": "0.12.15",
|
||||||
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
||||||
"author": "fyears",
|
"author": "fyears",
|
||||||
|
|||||||
+16
-4
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "remotely-save",
|
"name": "remotely-save",
|
||||||
"version": "0.2.4",
|
"version": "0.2.14",
|
||||||
"description": "This is yet another sync plugin for Obsidian app.",
|
"description": "This is yet another sync plugin for Obsidian app.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node esbuild.config.mjs",
|
"dev2": "node esbuild.config.mjs",
|
||||||
"build2": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
"build2": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
|
"dev": "webpack --mode development --watch",
|
||||||
"format": "npx prettier --write .",
|
"format": "npx prettier --write .",
|
||||||
"clean": "npx rimraf main.js",
|
"clean": "npx rimraf main.js",
|
||||||
"test": "cross-env TS_NODE_COMPILER_OPTIONS={\\\"module\\\":\\\"commonjs\\\"} mocha -r ts-node/register 'tests/**/*.ts'"
|
"test": "cross-env TS_NODE_COMPILER_OPTIONS={\\\"module\\\":\\\"commonjs\\\"} mocha -r ts-node/register 'tests/**/*.ts'"
|
||||||
@@ -13,16 +14,20 @@
|
|||||||
"browser": {
|
"browser": {
|
||||||
"path": "path-browserify",
|
"path": "path-browserify",
|
||||||
"process": "process/browser",
|
"process": "process/browser",
|
||||||
"stream": "stream-browserify"
|
"stream": "stream-browserify",
|
||||||
|
"crypto": "crypto-browserify"
|
||||||
},
|
},
|
||||||
"source": "main.ts",
|
"source": "main.ts",
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@microsoft/microsoft-graph-types": "^2.11.0",
|
||||||
"@types/chai": "^4.2.22",
|
"@types/chai": "^4.2.22",
|
||||||
"@types/chai-as-promised": "^7.1.4",
|
"@types/chai-as-promised": "^7.1.4",
|
||||||
|
"@types/feather-icons": "^4.7.0",
|
||||||
"@types/jsdom": "^16.2.13",
|
"@types/jsdom": "^16.2.13",
|
||||||
|
"@types/lodash": "^4.14.178",
|
||||||
"@types/mime-types": "^2.1.1",
|
"@types/mime-types": "^2.1.1",
|
||||||
"@types/mocha": "^9.0.0",
|
"@types/mocha": "^9.0.0",
|
||||||
"@types/node": "^14.14.37",
|
"@types/node": "^14.14.37",
|
||||||
@@ -32,7 +37,7 @@
|
|||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dotenv": "^10.0.0",
|
"dotenv": "^10.0.0",
|
||||||
"esbuild": "^0.14.0",
|
"esbuild": "^0.14.3",
|
||||||
"jsdom": "^19.0.0",
|
"jsdom": "^19.0.0",
|
||||||
"mocha": "^9.1.3",
|
"mocha": "^9.1.3",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^2.4.1",
|
||||||
@@ -48,6 +53,9 @@
|
|||||||
"@aws-sdk/client-s3": "^3.37.0",
|
"@aws-sdk/client-s3": "^3.37.0",
|
||||||
"@aws-sdk/lib-storage": "^3.40.1",
|
"@aws-sdk/lib-storage": "^3.40.1",
|
||||||
"@aws-sdk/signature-v4-crt": "^3.37.0",
|
"@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",
|
"acorn": "^8.5.0",
|
||||||
"assert": "^2.0.0",
|
"assert": "^2.0.0",
|
||||||
"aws-crt": "^1.10.1",
|
"aws-crt": "^1.10.1",
|
||||||
@@ -55,8 +63,12 @@
|
|||||||
"codemirror": "^5.63.1",
|
"codemirror": "^5.63.1",
|
||||||
"crypto-browserify": "^3.12.0",
|
"crypto-browserify": "^3.12.0",
|
||||||
"dropbox": "^10.22.0",
|
"dropbox": "^10.22.0",
|
||||||
|
"feather-icons": "^4.28.0",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"loglevel": "^1.8.0",
|
||||||
"mime-types": "^2.1.33",
|
"mime-types": "^2.1.33",
|
||||||
|
"nanoid": "^3.1.30",
|
||||||
"obsidian": "^0.12.0",
|
"obsidian": "^0.12.0",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
|
|||||||
+24
-1
@@ -3,7 +3,7 @@
|
|||||||
* To avoid circular dependency.
|
* To avoid circular dependency.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "dropbox";
|
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "dropbox" | "onedrive";
|
||||||
|
|
||||||
export interface S3Config {
|
export interface S3Config {
|
||||||
s3Endpoint: string;
|
s3Endpoint: string;
|
||||||
@@ -21,6 +21,7 @@ export interface DropboxConfig {
|
|||||||
accessTokenExpiresAtTime: number;
|
accessTokenExpiresAtTime: number;
|
||||||
accountID: string;
|
accountID: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
credentialsShouldBeDeletedAtTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WebdavAuthType = "digest" | "basic";
|
export type WebdavAuthType = "digest" | "basic";
|
||||||
@@ -30,14 +31,31 @@ export interface WebdavConfig {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
authType: WebdavAuthType;
|
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 {
|
export interface RemotelySavePluginSettings {
|
||||||
s3: S3Config;
|
s3: S3Config;
|
||||||
webdav: WebdavConfig;
|
webdav: WebdavConfig;
|
||||||
dropbox: DropboxConfig;
|
dropbox: DropboxConfig;
|
||||||
|
onedrive: OnedriveConfig;
|
||||||
password: string;
|
password: string;
|
||||||
serviceType: SUPPORTED_SERVICES_TYPE;
|
serviceType: SUPPORTED_SERVICES_TYPE;
|
||||||
|
currLogLevel?: string;
|
||||||
|
vaultRandomID?: string;
|
||||||
|
autoRunEveryMilliseconds?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteItem {
|
export interface RemoteItem {
|
||||||
@@ -50,6 +68,8 @@ export interface RemoteItem {
|
|||||||
|
|
||||||
export const COMMAND_URI = "remotely-save";
|
export const COMMAND_URI = "remotely-save";
|
||||||
export const COMMAND_CALLBACK = "remotely-save-cb";
|
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 {
|
export interface UriParams {
|
||||||
func?: string;
|
func?: string;
|
||||||
@@ -57,3 +77,6 @@ export interface UriParams {
|
|||||||
ver?: string;
|
ver?: string;
|
||||||
data?: string;
|
data?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 80 days
|
||||||
|
export const OAUTH2_FORCE_EXPIRE_MILLISECONDS = 1000 * 60 * 60 * 24 * 80;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
+12
-5
@@ -1,17 +1,24 @@
|
|||||||
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
||||||
|
|
||||||
import type { SyncPlanType } from "./sync";
|
import type { SyncPlanType } from "./sync";
|
||||||
import { readAllSyncPlanRecordTexts } from "./localdb";
|
import { readAllSyncPlanRecordTextsByVault } from "./localdb";
|
||||||
import type { InternalDBs } from "./localdb";
|
import type { InternalDBs } from "./localdb";
|
||||||
import { mkdirpInVault } from "./misc";
|
import { mkdirpInVault } from "./misc";
|
||||||
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
const DEFAULT_DEBUG_FOLDER = "_debug_remotely_save/";
|
const DEFAULT_DEBUG_FOLDER = "_debug_remotely_save/";
|
||||||
const DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX = "sync_plans_hist_exported_on_";
|
const DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX = "sync_plans_hist_exported_on_";
|
||||||
|
|
||||||
export const exportSyncPlansToFiles = async (db: InternalDBs, vault: Vault) => {
|
export const exportVaultSyncPlansToFiles = async (
|
||||||
console.log("exporting");
|
db: InternalDBs,
|
||||||
|
vault: Vault,
|
||||||
|
vaultRandomID: string
|
||||||
|
) => {
|
||||||
|
log.info("exporting");
|
||||||
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
||||||
const records = await readAllSyncPlanRecordTexts(db);
|
const records = await readAllSyncPlanRecordTextsByVault(db, vaultRandomID);
|
||||||
let md = "";
|
let md = "";
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
md = "No sync plans history found";
|
md = "No sync plans history found";
|
||||||
@@ -25,5 +32,5 @@ export const exportSyncPlansToFiles = async (db: InternalDBs, vault: Vault) => {
|
|||||||
await vault.create(filePath, md, {
|
await vault.create(filePath, md, {
|
||||||
mtime: ts,
|
mtime: ts,
|
||||||
});
|
});
|
||||||
console.log("finish exporting");
|
log.info("finish exporting");
|
||||||
};
|
};
|
||||||
|
|||||||
+38
-9
@@ -1,15 +1,15 @@
|
|||||||
import { base32, base64 } from "rfc4648";
|
import { base32, base64url } from "rfc4648";
|
||||||
import {
|
import { bufferToArrayBuffer, hexStringToTypedArray } from "./misc";
|
||||||
bufferToArrayBuffer,
|
|
||||||
arrayBufferToBuffer,
|
import * as origLog from "loglevel";
|
||||||
hexStringToTypedArray,
|
const log = origLog.getLogger("rs-default");
|
||||||
arrayBufferToHex,
|
|
||||||
} from "./misc";
|
|
||||||
|
|
||||||
const DEFAULT_ITER = 20000;
|
const DEFAULT_ITER = 20000;
|
||||||
|
|
||||||
// base32.stringify(Buffer.from('Salted__'))
|
// base32.stringify(Buffer.from('Salted__'))
|
||||||
export const MAGIC_ENCRYPTED_PREFIX_BASE32 = "KNQWY5DFMRPV";
|
export const MAGIC_ENCRYPTED_PREFIX_BASE32 = "KNQWY5DFMRPV";
|
||||||
|
// base64.stringify(Buffer.from('Salted__'))
|
||||||
|
export const MAGIC_ENCRYPTED_PREFIX_BASE64URL = "U2FsdGVkX";
|
||||||
|
|
||||||
const getKeyIVFromPassword = async (
|
const getKeyIVFromPassword = async (
|
||||||
salt: Uint8Array,
|
salt: Uint8Array,
|
||||||
@@ -120,7 +120,7 @@ export const encryptStringToBase32 = async (
|
|||||||
rounds,
|
rounds,
|
||||||
saltHex
|
saltHex
|
||||||
);
|
);
|
||||||
return base32.stringify(new Uint8Array(enc));
|
return base32.stringify(new Uint8Array(enc), { pad: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decryptBase32ToString = async (
|
export const decryptBase32ToString = async (
|
||||||
@@ -130,7 +130,36 @@ export const decryptBase32ToString = async (
|
|||||||
) => {
|
) => {
|
||||||
return new TextDecoder().decode(
|
return new TextDecoder().decode(
|
||||||
await decryptArrayBuffer(
|
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,
|
password,
|
||||||
rounds
|
rounds
|
||||||
)
|
)
|
||||||
|
|||||||
+10
-2
@@ -1,4 +1,5 @@
|
|||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
COMMAND_URI,
|
COMMAND_URI,
|
||||||
@@ -6,16 +7,23 @@ import {
|
|||||||
RemotelySavePluginSettings,
|
RemotelySavePluginSettings,
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
export const exportQrCodeUri = async (
|
export const exportQrCodeUri = async (
|
||||||
settings: RemotelySavePluginSettings,
|
settings: RemotelySavePluginSettings,
|
||||||
currentVaultName: string,
|
currentVaultName: string,
|
||||||
pluginVersion: string
|
pluginVersion: string
|
||||||
) => {
|
) => {
|
||||||
const data = encodeURIComponent(JSON.stringify(settings));
|
const settings2 = cloneDeep(settings);
|
||||||
|
delete settings2.dropbox;
|
||||||
|
delete settings2.onedrive;
|
||||||
|
delete settings2.vaultRandomID;
|
||||||
|
const data = encodeURIComponent(JSON.stringify(settings2));
|
||||||
const vault = encodeURIComponent(currentVaultName);
|
const vault = encodeURIComponent(currentVaultName);
|
||||||
const version = encodeURIComponent(pluginVersion);
|
const version = encodeURIComponent(pluginVersion);
|
||||||
const rawUri = `obsidian://${COMMAND_URI}?func=settings&version=${version}&vault=${vault}&data=${data}`;
|
const rawUri = `obsidian://${COMMAND_URI}?func=settings&version=${version}&vault=${vault}&data=${data}`;
|
||||||
// console.log(uri)
|
// log.info(uri)
|
||||||
const imgUri = await QRCode.toDataURL(rawUri);
|
const imgUri = await QRCode.toDataURL(rawUri);
|
||||||
return {
|
return {
|
||||||
rawUri,
|
rawUri,
|
||||||
|
|||||||
+167
-36
@@ -6,7 +6,11 @@ import type { SyncPlanType } from "./sync";
|
|||||||
|
|
||||||
export type LocalForage = typeof localforage;
|
export type LocalForage = typeof localforage;
|
||||||
|
|
||||||
export const DEFAULT_DB_VERSION_NUMBER: number = 20211114;
|
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_DB_NAME = "remotelysavedb";
|
||||||
export const DEFAULT_TBL_VERSION = "schemaversion";
|
export const DEFAULT_TBL_VERSION = "schemaversion";
|
||||||
export const DEFAULT_TBL_DELETE_HISTORY = "filefolderoperationhistory";
|
export const DEFAULT_TBL_DELETE_HISTORY = "filefolderoperationhistory";
|
||||||
@@ -22,6 +26,7 @@ export interface FileFolderHistoryRecord {
|
|||||||
actionType: "delete" | "rename";
|
actionType: "delete" | "rename";
|
||||||
keyType: "folder" | "file";
|
keyType: "folder" | "file";
|
||||||
renameTo: string;
|
renameTo: string;
|
||||||
|
vaultRandomID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncMetaMappingRecord {
|
interface SyncMetaMappingRecord {
|
||||||
@@ -34,12 +39,14 @@ interface SyncMetaMappingRecord {
|
|||||||
remoteExtraKey: string;
|
remoteExtraKey: string;
|
||||||
remoteType: SUPPORTED_SERVICES_TYPE;
|
remoteType: SUPPORTED_SERVICES_TYPE;
|
||||||
keyType: "folder" | "file";
|
keyType: "folder" | "file";
|
||||||
|
vaultRandomID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SyncPlanRecord {
|
interface SyncPlanRecord {
|
||||||
ts: number;
|
ts: number;
|
||||||
remoteType: string;
|
remoteType: string;
|
||||||
syncPlan: string;
|
syncPlan: string;
|
||||||
|
vaultRandomID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InternalDBs {
|
export interface InternalDBs {
|
||||||
@@ -49,7 +56,103 @@ export interface InternalDBs {
|
|||||||
syncPlansTbl: LocalForage;
|
syncPlansTbl: LocalForage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const prepareDBs = async () => {
|
/**
|
||||||
|
* 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}`);
|
||||||
|
|
||||||
|
const allPromisesToWait: Promise<any>[] = [];
|
||||||
|
|
||||||
|
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 = {
|
const db = {
|
||||||
versionTbl: localforage.createInstance({
|
versionTbl: localforage.createInstance({
|
||||||
name: DEFAULT_DB_NAME,
|
name: DEFAULT_DB_NAME,
|
||||||
@@ -71,14 +174,25 @@ export const prepareDBs = async () => {
|
|||||||
|
|
||||||
const originalVersion = (await db.versionTbl.getItem("version")) as number;
|
const originalVersion = (await db.versionTbl.getItem("version")) as number;
|
||||||
if (originalVersion === null) {
|
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);
|
await db.versionTbl.setItem("version", DEFAULT_DB_VERSION_NUMBER);
|
||||||
} else if (originalVersion === DEFAULT_DB_VERSION_NUMBER) {
|
} else if (originalVersion === DEFAULT_DB_VERSION_NUMBER) {
|
||||||
// do nothing
|
// do nothing
|
||||||
} else {
|
} else {
|
||||||
await migrateDBs(db, originalVersion, DEFAULT_DB_VERSION_NUMBER);
|
log.debug(
|
||||||
|
`trying to upgrade db version from ${originalVersion} to ${DEFAULT_DB_VERSION_NUMBER}`
|
||||||
|
);
|
||||||
|
await migrateDBs(
|
||||||
|
db,
|
||||||
|
originalVersion,
|
||||||
|
DEFAULT_DB_VERSION_NUMBER,
|
||||||
|
vaultRandomID
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("db connected");
|
log.info("db connected");
|
||||||
return db;
|
return db;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,10 +200,10 @@ export const destroyDBs = async () => {
|
|||||||
// await localforage.dropInstance({
|
// await localforage.dropInstance({
|
||||||
// name: DEFAULT_DB_NAME,
|
// name: DEFAULT_DB_NAME,
|
||||||
// });
|
// });
|
||||||
// console.log("db deleted");
|
// log.info("db deleted");
|
||||||
const req = indexedDB.deleteDatabase(DEFAULT_DB_NAME);
|
const req = indexedDB.deleteDatabase(DEFAULT_DB_NAME);
|
||||||
req.onsuccess = (event) => {
|
req.onsuccess = (event) => {
|
||||||
console.log("db deleted");
|
log.info("db deleted");
|
||||||
};
|
};
|
||||||
req.onblocked = (event) => {
|
req.onblocked = (event) => {
|
||||||
console.warn("trying to delete db but it was blocked");
|
console.warn("trying to delete db but it was blocked");
|
||||||
@@ -100,35 +214,34 @@ export const destroyDBs = async () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const migrateDBs = async (db: InternalDBs, oldVer: number, newVer: number) => {
|
export const loadDeleteRenameHistoryTableByVault = async (
|
||||||
if (oldVer === newVer) {
|
db: InternalDBs,
|
||||||
return;
|
vaultRandomID: string
|
||||||
}
|
) => {
|
||||||
// not implemented
|
|
||||||
throw Error(`not supported internal db changes from ${oldVer} to ${newVer}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadDeleteRenameHistoryTable = async (db: InternalDBs) => {
|
|
||||||
const records = [] as FileFolderHistoryRecord[];
|
const records = [] as FileFolderHistoryRecord[];
|
||||||
await db.deleteHistoryTbl.iterate((value, key, iterationNumber) => {
|
await db.deleteHistoryTbl.iterate((value, key, iterationNumber) => {
|
||||||
|
if (key.startsWith(`${vaultRandomID}\t`)) {
|
||||||
records.push(value as FileFolderHistoryRecord);
|
records.push(value as FileFolderHistoryRecord);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
records.sort((a, b) => a.actionWhen - b.actionWhen); // ascending
|
records.sort((a, b) => a.actionWhen - b.actionWhen); // ascending
|
||||||
return records;
|
return records;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearDeleteRenameHistoryOfKey = async (
|
export const clearDeleteRenameHistoryOfKeyAndVault = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
key: string
|
key: string,
|
||||||
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
await db.deleteHistoryTbl.removeItem(key);
|
await db.deleteHistoryTbl.removeItem(`${vaultRandomID}\t${key}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const insertDeleteRecord = async (
|
export const insertDeleteRecordByVault = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
fileOrFolder: TAbstractFile
|
fileOrFolder: TAbstractFile,
|
||||||
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
// console.log(fileOrFolder);
|
// log.info(fileOrFolder);
|
||||||
let k: FileFolderHistoryRecord;
|
let k: FileFolderHistoryRecord;
|
||||||
if (fileOrFolder instanceof TFile) {
|
if (fileOrFolder instanceof TFile) {
|
||||||
k = {
|
k = {
|
||||||
@@ -140,6 +253,7 @@ export const insertDeleteRecord = async (
|
|||||||
actionType: "delete",
|
actionType: "delete",
|
||||||
keyType: "file",
|
keyType: "file",
|
||||||
renameTo: "",
|
renameTo: "",
|
||||||
|
vaultRandomID: vaultRandomID,
|
||||||
};
|
};
|
||||||
} else if (fileOrFolder instanceof TFolder) {
|
} else if (fileOrFolder instanceof TFolder) {
|
||||||
// key should endswith "/"
|
// key should endswith "/"
|
||||||
@@ -155,17 +269,19 @@ export const insertDeleteRecord = async (
|
|||||||
actionType: "delete",
|
actionType: "delete",
|
||||||
keyType: "folder",
|
keyType: "folder",
|
||||||
renameTo: "",
|
renameTo: "",
|
||||||
|
vaultRandomID: vaultRandomID,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
await db.deleteHistoryTbl.setItem(k.key, k);
|
await db.deleteHistoryTbl.setItem(`${vaultRandomID}\t${k.key}`, k);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const insertRenameRecord = async (
|
export const insertRenameRecordByVault = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
fileOrFolder: TAbstractFile,
|
fileOrFolder: TAbstractFile,
|
||||||
oldPath: string
|
oldPath: string,
|
||||||
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
// console.log(fileOrFolder);
|
// log.info(fileOrFolder);
|
||||||
let k: FileFolderHistoryRecord;
|
let k: FileFolderHistoryRecord;
|
||||||
if (fileOrFolder instanceof TFile) {
|
if (fileOrFolder instanceof TFile) {
|
||||||
k = {
|
k = {
|
||||||
@@ -177,6 +293,7 @@ export const insertRenameRecord = async (
|
|||||||
actionType: "rename",
|
actionType: "rename",
|
||||||
keyType: "file",
|
keyType: "file",
|
||||||
renameTo: fileOrFolder.path,
|
renameTo: fileOrFolder.path,
|
||||||
|
vaultRandomID: vaultRandomID,
|
||||||
};
|
};
|
||||||
} else if (fileOrFolder instanceof TFolder) {
|
} else if (fileOrFolder instanceof TFolder) {
|
||||||
const key = oldPath.endsWith("/") ? oldPath : `${oldPath}/`;
|
const key = oldPath.endsWith("/") ? oldPath : `${oldPath}/`;
|
||||||
@@ -192,12 +309,13 @@ export const insertRenameRecord = async (
|
|||||||
actionType: "rename",
|
actionType: "rename",
|
||||||
keyType: "folder",
|
keyType: "folder",
|
||||||
renameTo: renameTo,
|
renameTo: renameTo,
|
||||||
|
vaultRandomID: vaultRandomID,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
await db.deleteHistoryTbl.setItem(k.key, k);
|
await db.deleteHistoryTbl.setItem(`${vaultRandomID}\t${k.key}`, k);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const upsertSyncMetaMappingData = async (
|
export const upsertSyncMetaMappingDataByVault = async (
|
||||||
serviceType: SUPPORTED_SERVICES_TYPE,
|
serviceType: SUPPORTED_SERVICES_TYPE,
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
localKey: string,
|
localKey: string,
|
||||||
@@ -206,7 +324,8 @@ export const upsertSyncMetaMappingData = async (
|
|||||||
remoteKey: string,
|
remoteKey: string,
|
||||||
remoteMTime: number,
|
remoteMTime: number,
|
||||||
remoteSize: number,
|
remoteSize: number,
|
||||||
remoteExtraKey: string /* ETag from s3 */
|
remoteExtraKey: string,
|
||||||
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
const aggregratedInfo: SyncMetaMappingRecord = {
|
const aggregratedInfo: SyncMetaMappingRecord = {
|
||||||
localKey: localKey,
|
localKey: localKey,
|
||||||
@@ -218,19 +337,24 @@ export const upsertSyncMetaMappingData = async (
|
|||||||
remoteExtraKey: remoteExtraKey,
|
remoteExtraKey: remoteExtraKey,
|
||||||
remoteType: serviceType,
|
remoteType: serviceType,
|
||||||
keyType: localKey.endsWith("/") ? "folder" : "file",
|
keyType: localKey.endsWith("/") ? "folder" : "file",
|
||||||
|
vaultRandomID: vaultRandomID,
|
||||||
};
|
};
|
||||||
await db.syncMappingTbl.setItem(remoteKey, aggregratedInfo);
|
await db.syncMappingTbl.setItem(
|
||||||
|
`${vaultRandomID}\t${remoteKey}`,
|
||||||
|
aggregratedInfo
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSyncMetaMappingByRemoteKey = async (
|
export const getSyncMetaMappingByRemoteKeyAndVault = async (
|
||||||
serviceType: SUPPORTED_SERVICES_TYPE,
|
serviceType: SUPPORTED_SERVICES_TYPE,
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
remoteKey: string,
|
remoteKey: string,
|
||||||
remoteMTime: number,
|
remoteMTime: number,
|
||||||
remoteExtraKey: string
|
remoteExtraKey: string,
|
||||||
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
const potentialItem = (await db.syncMappingTbl.getItem(
|
const potentialItem = (await db.syncMappingTbl.getItem(
|
||||||
remoteKey
|
`${vaultRandomID}\t${remoteKey}`
|
||||||
)) as SyncMetaMappingRecord;
|
)) as SyncMetaMappingRecord;
|
||||||
|
|
||||||
if (potentialItem === null) {
|
if (potentialItem === null) {
|
||||||
@@ -255,26 +379,33 @@ export const clearAllSyncMetaMapping = async (db: InternalDBs) => {
|
|||||||
await db.syncMappingTbl.clear();
|
await db.syncMappingTbl.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const insertSyncPlanRecord = async (
|
export const insertSyncPlanRecordByVault = async (
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
syncPlan: SyncPlanType
|
syncPlan: SyncPlanType,
|
||||||
|
vaultRandomID: string
|
||||||
) => {
|
) => {
|
||||||
const record = {
|
const record = {
|
||||||
ts: syncPlan.ts,
|
ts: syncPlan.ts,
|
||||||
|
vaultRandomID: vaultRandomID,
|
||||||
remoteType: syncPlan.remoteType,
|
remoteType: syncPlan.remoteType,
|
||||||
syncPlan: JSON.stringify(syncPlan /* directly stringify */, null, 2),
|
syncPlan: JSON.stringify(syncPlan /* directly stringify */, null, 2),
|
||||||
} as SyncPlanRecord;
|
} as SyncPlanRecord;
|
||||||
await db.syncPlansTbl.setItem(`${syncPlan.ts}`, record);
|
await db.syncPlansTbl.setItem(`${vaultRandomID}\t${syncPlan.ts}`, record);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearAllSyncPlanRecords = async (db: InternalDBs) => {
|
export const clearAllSyncPlanRecords = async (db: InternalDBs) => {
|
||||||
await db.syncPlansTbl.clear();
|
await db.syncPlansTbl.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const readAllSyncPlanRecordTexts = async (db: InternalDBs) => {
|
export const readAllSyncPlanRecordTextsByVault = async (
|
||||||
|
db: InternalDBs,
|
||||||
|
vaultRandomID: string
|
||||||
|
) => {
|
||||||
const records = [] as SyncPlanRecord[];
|
const records = [] as SyncPlanRecord[];
|
||||||
await db.syncPlansTbl.iterate((value, key, iterationNumber) => {
|
await db.syncPlansTbl.iterate((value, key, iterationNumber) => {
|
||||||
|
if (key.startsWith(`${vaultRandomID}\t`)) {
|
||||||
records.push(value as SyncPlanRecord);
|
records.push(value as SyncPlanRecord);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
records.sort((a, b) => -(a.ts - b.ts)); // descending
|
records.sort((a, b) => -(a.ts - b.ts)); // descending
|
||||||
|
|
||||||
|
|||||||
+508
-843
File diff suppressed because it is too large
Load Diff
+51
-6
@@ -1,9 +1,12 @@
|
|||||||
import { Vault } from "obsidian";
|
import { Vault } from "obsidian";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
import { base32 } from "rfc4648";
|
import { base32, base64url } from "rfc4648";
|
||||||
import XRegExp from "xregexp";
|
import XRegExp from "xregexp";
|
||||||
|
|
||||||
|
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.
|
* If any part of the file starts with '.' or '_' then it's a hidden file.
|
||||||
* @param item
|
* @param item
|
||||||
@@ -13,7 +16,7 @@ import XRegExp from "xregexp";
|
|||||||
export const isHiddenPath = (item: string, loose: boolean = true) => {
|
export const isHiddenPath = (item: string, loose: boolean = true) => {
|
||||||
const k = path.posix.normalize(item); // TODO: only unix path now
|
const k = path.posix.normalize(item); // TODO: only unix path now
|
||||||
const k2 = k.split("/"); // 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) {
|
for (const singlePart of k2) {
|
||||||
if (singlePart === "." || singlePart === ".." || singlePart === "") {
|
if (singlePart === "." || singlePart === ".." || singlePart === "") {
|
||||||
continue;
|
continue;
|
||||||
@@ -54,14 +57,14 @@ export const getFolderLevels = (x: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const mkdirpInVault = async (thePath: string, vault: Vault) => {
|
export const mkdirpInVault = async (thePath: string, vault: Vault) => {
|
||||||
// console.log(thePath);
|
// log.info(thePath);
|
||||||
const foldersToBuild = getFolderLevels(thePath);
|
const foldersToBuild = getFolderLevels(thePath);
|
||||||
// console.log(foldersToBuild);
|
// log.info(foldersToBuild);
|
||||||
for (const folder of foldersToBuild) {
|
for (const folder of foldersToBuild) {
|
||||||
const r = await vault.adapter.exists(folder);
|
const r = await vault.adapter.exists(folder);
|
||||||
// console.log(r);
|
// log.info(r);
|
||||||
if (!r) {
|
if (!r) {
|
||||||
console.log(`mkdir ${folder}`);
|
log.info(`mkdir ${folder}`);
|
||||||
await vault.adapter.mkdir(folder);
|
await vault.adapter.mkdir(folder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +117,14 @@ export const base64ToBase32 = (a: string) => {
|
|||||||
return base32.stringify(Buffer.from(a, "base64"));
|
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!
|
* iOS Safari could decrypt string with invalid password!
|
||||||
* So we need an extra way to test the decrypted result.
|
* So we need an extra way to test the decrypted result.
|
||||||
@@ -163,3 +174,37 @@ export const extractSvgSub = (x: string, subEl: string = "rect") => {
|
|||||||
svg.setAttribute("viewbox", "0 0 10 10");
|
svg.setAttribute("viewbox", "0 0 10 10");
|
||||||
return svg.innerHTML;
|
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("");
|
||||||
|
};
|
||||||
|
|||||||
+60
-4
@@ -1,14 +1,18 @@
|
|||||||
import { Vault } from "obsidian";
|
import { Vault } from "obsidian";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
SUPPORTED_SERVICES_TYPE,
|
|
||||||
S3Config,
|
|
||||||
DropboxConfig,
|
DropboxConfig,
|
||||||
|
OnedriveConfig,
|
||||||
|
S3Config,
|
||||||
|
SUPPORTED_SERVICES_TYPE,
|
||||||
WebdavConfig,
|
WebdavConfig,
|
||||||
} from "./baseTypes";
|
} from "./baseTypes";
|
||||||
|
import * as dropbox from "./remoteForDropbox";
|
||||||
|
import * as onedrive from "./remoteForOnedrive";
|
||||||
import * as s3 from "./remoteForS3";
|
import * as s3 from "./remoteForS3";
|
||||||
import * as webdav from "./remoteForWebdav";
|
import * as webdav from "./remoteForWebdav";
|
||||||
import * as dropbox from "./remoteForDropbox";
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
export class RemoteClient {
|
export class RemoteClient {
|
||||||
readonly serviceType: SUPPORTED_SERVICES_TYPE;
|
readonly serviceType: SUPPORTED_SERVICES_TYPE;
|
||||||
@@ -18,12 +22,15 @@ export class RemoteClient {
|
|||||||
readonly webdavConfig?: WebdavConfig;
|
readonly webdavConfig?: WebdavConfig;
|
||||||
readonly dropboxClient?: dropbox.WrappedDropboxClient;
|
readonly dropboxClient?: dropbox.WrappedDropboxClient;
|
||||||
readonly dropboxConfig?: DropboxConfig;
|
readonly dropboxConfig?: DropboxConfig;
|
||||||
|
readonly onedriveClient?: onedrive.WrappedOnedriveClient;
|
||||||
|
readonly onedriveConfig?: OnedriveConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
serviceType: SUPPORTED_SERVICES_TYPE,
|
serviceType: SUPPORTED_SERVICES_TYPE,
|
||||||
s3Config?: S3Config,
|
s3Config?: S3Config,
|
||||||
webdavConfig?: WebdavConfig,
|
webdavConfig?: WebdavConfig,
|
||||||
dropboxConfig?: DropboxConfig,
|
dropboxConfig?: DropboxConfig,
|
||||||
|
onedriveConfig?: OnedriveConfig,
|
||||||
vaultName?: string,
|
vaultName?: string,
|
||||||
saveUpdatedConfigFunc?: () => Promise<any>
|
saveUpdatedConfigFunc?: () => Promise<any>
|
||||||
) {
|
) {
|
||||||
@@ -51,6 +58,18 @@ export class RemoteClient {
|
|||||||
vaultName,
|
vaultName,
|
||||||
saveUpdatedConfigFunc
|
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 {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
@@ -67,6 +86,11 @@ export class RemoteClient {
|
|||||||
return await webdav.getRemoteMeta(this.webdavClient, fileOrFolderPath);
|
return await webdav.getRemoteMeta(this.webdavClient, fileOrFolderPath);
|
||||||
} else if (this.serviceType === "dropbox") {
|
} else if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.getRemoteMeta(this.dropboxClient, fileOrFolderPath);
|
return await dropbox.getRemoteMeta(this.dropboxClient, fileOrFolderPath);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.getRemoteMeta(
|
||||||
|
this.onedriveClient,
|
||||||
|
fileOrFolderPath
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
@@ -109,6 +133,16 @@ export class RemoteClient {
|
|||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
foldersCreatedBefore
|
foldersCreatedBefore
|
||||||
);
|
);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.uploadToRemote(
|
||||||
|
this.onedriveClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
vault,
|
||||||
|
isRecursively,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey,
|
||||||
|
foldersCreatedBefore
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
@@ -121,6 +155,8 @@ export class RemoteClient {
|
|||||||
return await webdav.listFromRemote(this.webdavClient, prefix);
|
return await webdav.listFromRemote(this.webdavClient, prefix);
|
||||||
} else if (this.serviceType === "dropbox") {
|
} else if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.listFromRemote(this.dropboxClient, prefix);
|
return await dropbox.listFromRemote(this.dropboxClient, prefix);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.listFromRemote(this.onedriveClient, prefix);
|
||||||
} else {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
@@ -161,6 +197,15 @@ export class RemoteClient {
|
|||||||
password,
|
password,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey
|
||||||
);
|
);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.downloadFromRemote(
|
||||||
|
this.onedriveClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
vault,
|
||||||
|
mtime,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
@@ -193,6 +238,13 @@ export class RemoteClient {
|
|||||||
password,
|
password,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey
|
||||||
);
|
);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.deleteFromRemote(
|
||||||
|
this.onedriveClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
@@ -205,6 +257,8 @@ export class RemoteClient {
|
|||||||
return await webdav.checkConnectivity(this.webdavClient);
|
return await webdav.checkConnectivity(this.webdavClient);
|
||||||
} else if (this.serviceType === "dropbox") {
|
} else if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.checkConnectivity(this.dropboxClient);
|
return await dropbox.checkConnectivity(this.dropboxClient);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.checkConnectivity(this.onedriveClient);
|
||||||
} else {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
@@ -213,6 +267,8 @@ export class RemoteClient {
|
|||||||
getUser = async () => {
|
getUser = async () => {
|
||||||
if (this.serviceType === "dropbox") {
|
if (this.serviceType === "dropbox") {
|
||||||
return await dropbox.getUserDisplayName(this.dropboxClient);
|
return await dropbox.getUserDisplayName(this.dropboxClient);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.getUserDisplayName(this.onedriveClient);
|
||||||
} else {
|
} else {
|
||||||
throw Error(`not supported service type ${this.serviceType}`);
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-24
@@ -1,20 +1,21 @@
|
|||||||
|
import { Dropbox, DropboxAuth, files } from "dropbox";
|
||||||
|
import { Vault } from "obsidian";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { FileStats, Vault } from "obsidian";
|
|
||||||
|
|
||||||
import { Dropbox, DropboxAuth, DropboxResponse, files } from "dropbox";
|
|
||||||
export { Dropbox } from "dropbox";
|
|
||||||
import { RemoteItem, DropboxConfig } from "./baseTypes";
|
|
||||||
import {
|
import {
|
||||||
arrayBufferToBuffer,
|
DropboxConfig,
|
||||||
bufferToArrayBuffer,
|
RemoteItem,
|
||||||
mkdirpInVault,
|
COMMAND_CALLBACK_DROPBOX,
|
||||||
getPathFolder,
|
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||||
getFolderLevels,
|
} from "./baseTypes";
|
||||||
setToString,
|
|
||||||
} from "./misc";
|
|
||||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
||||||
|
import { bufferToArrayBuffer, getFolderLevels, mkdirpInVault } from "./misc";
|
||||||
|
|
||||||
export const DEFAULT_DROPBOX_CONFIG = {
|
export { Dropbox } from "dropbox";
|
||||||
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
|
export const DEFAULT_DROPBOX_CONFIG: DropboxConfig = {
|
||||||
accessToken: "",
|
accessToken: "",
|
||||||
clientID: process.env.DEFAULT_DROPBOX_APP_KEY,
|
clientID: process.env.DEFAULT_DROPBOX_APP_KEY,
|
||||||
refreshToken: "",
|
refreshToken: "",
|
||||||
@@ -22,6 +23,7 @@ export const DEFAULT_DROPBOX_CONFIG = {
|
|||||||
accessTokenExpiresAtTime: 0,
|
accessTokenExpiresAtTime: 0,
|
||||||
accountID: "",
|
accountID: "",
|
||||||
username: "",
|
username: "",
|
||||||
|
credentialsShouldBeDeletedAtTime: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDropboxPath = (fileOrFolderPath: string, vaultName: string) => {
|
export const getDropboxPath = (fileOrFolderPath: string, vaultName: string) => {
|
||||||
@@ -160,7 +162,7 @@ export const getAuthUrlAndVerifier = async (appKey: string) => {
|
|||||||
});
|
});
|
||||||
const authUrl = (
|
const authUrl = (
|
||||||
await auth.getAuthenticationUrl(
|
await auth.getAuthenticationUrl(
|
||||||
undefined,
|
`obsidian://${COMMAND_CALLBACK_DROPBOX}`,
|
||||||
undefined,
|
undefined,
|
||||||
"code",
|
"code",
|
||||||
"offline",
|
"offline",
|
||||||
@@ -198,6 +200,7 @@ export const sendAuthReq = async (
|
|||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
code_verifier: verifier,
|
code_verifier: verifier,
|
||||||
client_id: appKey,
|
client_id: appKey,
|
||||||
|
redirect_uri: `obsidian://${COMMAND_CALLBACK_DROPBOX}`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
||||||
@@ -208,7 +211,7 @@ export const sendRefreshTokenReq = async (
|
|||||||
appKey: string,
|
appKey: string,
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
) => {
|
) => {
|
||||||
console.log("start auto getting refreshed Dropbox access token.");
|
log.info("start auto getting refreshed Dropbox access token.");
|
||||||
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
@@ -218,7 +221,7 @@ export const sendRefreshTokenReq = async (
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
||||||
console.log("finish auto getting refreshed Dropbox access token.");
|
log.info("finish auto getting refreshed Dropbox access token.");
|
||||||
return resp2;
|
return resp2;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -227,13 +230,17 @@ export const setConfigBySuccessfullAuthInplace = async (
|
|||||||
authRes: DropboxSuccessAuthRes,
|
authRes: DropboxSuccessAuthRes,
|
||||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||||
) => {
|
) => {
|
||||||
console.log("start updating local info of Dropbox token");
|
log.info("start updating local info of Dropbox token");
|
||||||
|
|
||||||
config.accessToken = authRes.access_token;
|
config.accessToken = authRes.access_token;
|
||||||
config.accessTokenExpiresInSeconds = parseInt(authRes.expires_in);
|
config.accessTokenExpiresInSeconds = parseInt(authRes.expires_in);
|
||||||
config.accessTokenExpiresAtTime =
|
config.accessTokenExpiresAtTime =
|
||||||
Date.now() + parseInt(authRes.expires_in) * 1000 - 10 * 1000;
|
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) {
|
if (authRes.refresh_token !== undefined) {
|
||||||
config.refreshToken = authRes.refresh_token;
|
config.refreshToken = authRes.refresh_token;
|
||||||
}
|
}
|
||||||
@@ -245,7 +252,7 @@ export const setConfigBySuccessfullAuthInplace = async (
|
|||||||
await saveUpdatedConfigFunc();
|
await saveUpdatedConfigFunc();
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("finish updating local info of Dropbox token");
|
log.info("finish updating local info of Dropbox token");
|
||||||
};
|
};
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -304,9 +311,9 @@ export class WrappedDropboxClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// check vault folder
|
// check vault folder
|
||||||
// console.log(`checking remote has folder /${this.vaultName}`);
|
// log.info(`checking remote has folder /${this.vaultName}`);
|
||||||
if (this.vaultFolderExists) {
|
if (this.vaultFolderExists) {
|
||||||
// console.log(`already checked, /${this.vaultName} exist before`)
|
// log.info(`already checked, /${this.vaultName} exist before`)
|
||||||
} else {
|
} else {
|
||||||
const res = await this.dropbox.filesListFolder({
|
const res = await this.dropbox.filesListFolder({
|
||||||
path: "",
|
path: "",
|
||||||
@@ -319,14 +326,14 @@ export class WrappedDropboxClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!this.vaultFolderExists) {
|
if (!this.vaultFolderExists) {
|
||||||
console.log(`remote does not have folder /${this.vaultName}`);
|
log.info(`remote does not have folder /${this.vaultName}`);
|
||||||
await this.dropbox.filesCreateFolderV2({
|
await this.dropbox.filesCreateFolderV2({
|
||||||
path: `/${this.vaultName}`,
|
path: `/${this.vaultName}`,
|
||||||
});
|
});
|
||||||
console.log(`remote folder /${this.vaultName} created`);
|
log.info(`remote folder /${this.vaultName} created`);
|
||||||
this.vaultFolderExists = true;
|
this.vaultFolderExists = true;
|
||||||
} else {
|
} else {
|
||||||
// console.log(`remote folder /${this.vaultName} exists`);
|
// log.info(`remote folder /${this.vaultName} exists`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,7 +492,7 @@ export const listFromRemote = async (
|
|||||||
if (res.status !== 200) {
|
if (res.status !== 200) {
|
||||||
throw Error(JSON.stringify(res));
|
throw Error(JSON.stringify(res));
|
||||||
}
|
}
|
||||||
// console.log(res);
|
// log.info(res);
|
||||||
|
|
||||||
const contents = res.result.entries;
|
const contents = res.result.entries;
|
||||||
const unifiedContents = contents
|
const unifiedContents = contents
|
||||||
|
|||||||
@@ -0,0 +1,830 @@
|
|||||||
|
import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
|
||||||
|
import { AuthenticationProvider } from "@microsoft/microsoft-graph-client";
|
||||||
|
import type {
|
||||||
|
DriveItem,
|
||||||
|
UploadSession,
|
||||||
|
User,
|
||||||
|
} from "@microsoft/microsoft-graph-types";
|
||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
import { request, Vault } from "obsidian";
|
||||||
|
import {
|
||||||
|
COMMAND_CALLBACK_ONEDRIVE,
|
||||||
|
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||||
|
OnedriveConfig,
|
||||||
|
RemoteItem,
|
||||||
|
} from "./baseTypes";
|
||||||
|
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
||||||
|
import {
|
||||||
|
getRandomArrayBuffer,
|
||||||
|
getRandomIntInclusive,
|
||||||
|
mkdirpInVault,
|
||||||
|
} from "./misc";
|
||||||
|
|
||||||
|
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.cTag || "", // do NOT use x.eTag because it changes if meta changes
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
vaultFolderExists: boolean;
|
||||||
|
authGetter: MyAuthProvider;
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>;
|
||||||
|
constructor(
|
||||||
|
onedriveConfig: OnedriveConfig,
|
||||||
|
vaultName: string,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>
|
||||||
|
) {
|
||||||
|
this.onedriveConfig = onedriveConfig;
|
||||||
|
this.vaultName = vaultName;
|
||||||
|
this.vaultFolderExists = false;
|
||||||
|
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||||
|
this.authGetter = 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.getJson("/drive/special/approot/children");
|
||||||
|
log.debug(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.postJson("/drive/special/approot/children", {
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
buildUrl = (pathFragOrig: string) => {
|
||||||
|
const API_PREFIX = "https://graph.microsoft.com/v1.0";
|
||||||
|
let theUrl = "";
|
||||||
|
if (
|
||||||
|
pathFragOrig.startsWith("http://") ||
|
||||||
|
pathFragOrig.startsWith("https://")
|
||||||
|
) {
|
||||||
|
theUrl = pathFragOrig;
|
||||||
|
} else {
|
||||||
|
const pathFrag = encodeURI(pathFragOrig);
|
||||||
|
theUrl = `${API_PREFIX}${pathFrag}`;
|
||||||
|
}
|
||||||
|
return theUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
getJson = async (pathFragOrig: string) => {
|
||||||
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
|
log.debug(`getJson, theUrl=${theUrl}`);
|
||||||
|
return JSON.parse(
|
||||||
|
await request({
|
||||||
|
url: theUrl,
|
||||||
|
method: "GET",
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await this.authGetter.getAccessToken()}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
postJson = async (pathFragOrig: string, payload: any) => {
|
||||||
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
|
log.debug(`postJson, theUrl=${theUrl}`);
|
||||||
|
return JSON.parse(
|
||||||
|
await request({
|
||||||
|
url: theUrl,
|
||||||
|
method: "POST",
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await this.authGetter.getAccessToken()}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
patchJson = async (pathFragOrig: string, payload: any) => {
|
||||||
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
|
log.debug(`patchJson, theUrl=${theUrl}`);
|
||||||
|
return JSON.parse(
|
||||||
|
await request({
|
||||||
|
url: theUrl,
|
||||||
|
method: "PATCH",
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await this.authGetter.getAccessToken()}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteJson = async (pathFragOrig: string) => {
|
||||||
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
|
log.debug(`deleteJson, theUrl=${theUrl}`);
|
||||||
|
// TODO: delete does not have response, so Obsidian request may have error
|
||||||
|
// currently downgraded to fetch()!
|
||||||
|
await fetch(theUrl, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await this.authGetter.getAccessToken()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
putArrayBuffer = async (pathFragOrig: string, payload: ArrayBuffer) => {
|
||||||
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
|
log.debug(`putArrayBuffer, theUrl=${theUrl}`);
|
||||||
|
// TODO: Obsidian doesn't support ArrayBuffer
|
||||||
|
// currently downgraded to fetch()!
|
||||||
|
await fetch(theUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: payload,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await this.authGetter.getAccessToken()}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A specialized function to upload large files by parts
|
||||||
|
* @param pathFragOrig
|
||||||
|
* @param payload
|
||||||
|
* @param rangeMin
|
||||||
|
* @param rangeEnd the end, exclusive
|
||||||
|
* @param size
|
||||||
|
*/
|
||||||
|
putUint8ArrayByRange = async (
|
||||||
|
pathFragOrig: string,
|
||||||
|
payload: Uint8Array,
|
||||||
|
rangeStart: number,
|
||||||
|
rangeEnd: number,
|
||||||
|
size: number
|
||||||
|
) => {
|
||||||
|
const theUrl = this.buildUrl(pathFragOrig);
|
||||||
|
log.debug(
|
||||||
|
`putUint8ArrayByRange, theUrl=${theUrl}, range=${rangeStart}-${
|
||||||
|
rangeEnd - 1
|
||||||
|
}, len=${rangeEnd - rangeStart}, size=${size}`
|
||||||
|
);
|
||||||
|
// TODO: Obsidian doesn't support ArrayBuffer
|
||||||
|
// currently downgraded to fetch()!
|
||||||
|
// AND, NO AUTH HEADER here!
|
||||||
|
const res = await fetch(theUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
body: payload.subarray(rangeStart, rangeEnd),
|
||||||
|
headers: {
|
||||||
|
"Content-Length": `${rangeEnd - rangeStart}`,
|
||||||
|
"Content-Range": `bytes ${rangeStart}-${rangeEnd - 1}/${size}`,
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json() as DriveItem | UploadSession;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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.getJson(
|
||||||
|
`/drive/special/approot:/${client.vaultName}:/delta`
|
||||||
|
);
|
||||||
|
let driveItems = res.value as DriveItem[];
|
||||||
|
|
||||||
|
while (NEXT_LINK_KEY in res) {
|
||||||
|
res = await client.getJson(res[NEXT_LINK_KEY]);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.getJson(
|
||||||
|
`${remotePath}?$select=cTag,eTag,fileSystemInfo,folder,file,name,parentReference,size`
|
||||||
|
);
|
||||||
|
// 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.debug(`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.patchJson(uploadFile, {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
// an encrypted folder is always small, we just use put here
|
||||||
|
await client.putArrayBuffer(
|
||||||
|
`${uploadFile}:/content?${new URLSearchParams({
|
||||||
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
|
})}`,
|
||||||
|
arrBufRandom
|
||||||
|
);
|
||||||
|
// 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!
|
||||||
|
|
||||||
|
// upload large files!
|
||||||
|
// ref: https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_createuploadsession?view=odsp-graph-online
|
||||||
|
|
||||||
|
// 1. create uploadSession
|
||||||
|
// uploadFile already starts with /drive/special/approot:/${vaultName}
|
||||||
|
const s: UploadSession = await client.postJson(
|
||||||
|
`${uploadFile}:/createUploadSession`,
|
||||||
|
{
|
||||||
|
item: {
|
||||||
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const uploadUrl = s.uploadUrl;
|
||||||
|
log.debug("uploadSession = ");
|
||||||
|
log.debug(s);
|
||||||
|
|
||||||
|
// 2. upload by ranges
|
||||||
|
// convert to uint8
|
||||||
|
const uint8 = new Uint8Array(remoteContent);
|
||||||
|
// hard code range size
|
||||||
|
const MIN_UNIT = 327680; // bytes in msft doc, about 0.32768 MB
|
||||||
|
const RANGE_SIZE = MIN_UNIT * 20; // about 6.5536 MB
|
||||||
|
// upload the ranges one by one
|
||||||
|
let rangeStart = 0;
|
||||||
|
while (rangeStart < uint8.byteLength) {
|
||||||
|
await client.putUint8ArrayByRange(
|
||||||
|
uploadUrl,
|
||||||
|
uint8,
|
||||||
|
rangeStart,
|
||||||
|
Math.min(rangeStart + RANGE_SIZE, uint8.byteLength),
|
||||||
|
uint8.byteLength
|
||||||
|
);
|
||||||
|
rangeStart += RANGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.getJson(
|
||||||
|
`${key}?$select=@microsoft.graph.downloadUrl`
|
||||||
|
);
|
||||||
|
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.deleteJson(remoteFileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
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.getJson("/me?$select=displayName");
|
||||||
|
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.postJson('/me/revokeSignInSessions', {});
|
||||||
|
// };
|
||||||
|
|
||||||
|
export const getRevokeAddr = async () => {
|
||||||
|
return "https://account.live.com/consent/Manage";
|
||||||
|
};
|
||||||
+23
-25
@@ -1,34 +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,
|
|
||||||
HeadObjectCommandOutput,
|
|
||||||
} from "@aws-sdk/client-s3";
|
|
||||||
export { S3Client } from "@aws-sdk/client-s3";
|
|
||||||
|
|
||||||
import type { _Object } 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 {
|
import {
|
||||||
arrayBufferToBuffer,
|
arrayBufferToBuffer,
|
||||||
bufferToArrayBuffer,
|
bufferToArrayBuffer,
|
||||||
mkdirpInVault,
|
mkdirpInVault,
|
||||||
} from "./misc";
|
} from "./misc";
|
||||||
import * as mime from "mime-types";
|
|
||||||
|
|
||||||
import { RemoteItem, S3Config } from "./baseTypes";
|
export { S3Client } from "@aws-sdk/client-s3";
|
||||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
export const DEFAULT_S3_CONFIG = {
|
export const DEFAULT_S3_CONFIG = {
|
||||||
s3Endpoint: "",
|
s3Endpoint: "",
|
||||||
@@ -155,7 +153,7 @@ export const uploadToRemote = async (
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
upload.on("httpUploadProgress", (progress) => {
|
upload.on("httpUploadProgress", (progress) => {
|
||||||
// console.log(progress);
|
// log.info(progress);
|
||||||
});
|
});
|
||||||
await upload.done();
|
await upload.done();
|
||||||
|
|
||||||
|
|||||||
+59
-22
@@ -1,24 +1,24 @@
|
|||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
import { FileStats, Vault } from "obsidian";
|
import { Vault } from "obsidian";
|
||||||
|
import type { FileStat, WebDAVClient } from "webdav/web";
|
||||||
import { AuthType, BufferLike, createClient } from "webdav/web";
|
import { AuthType, BufferLike, createClient } from "webdav/web";
|
||||||
import type { WebDAVClient, ResponseDataDetailed, FileStat } 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";
|
export type { WebDAVClient } from "webdav/web";
|
||||||
|
|
||||||
import type { RemoteItem, WebdavAuthType, WebdavConfig } from "./baseTypes";
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
import {
|
|
||||||
arrayBufferToBuffer,
|
|
||||||
bufferToArrayBuffer,
|
|
||||||
mkdirpInVault,
|
|
||||||
getPathFolder,
|
|
||||||
} from "./misc";
|
|
||||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
||||||
|
|
||||||
export const DEFAULT_WEBDAV_CONFIG = {
|
export const DEFAULT_WEBDAV_CONFIG = {
|
||||||
address: "",
|
address: "",
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
authType: "basic",
|
authType: "basic",
|
||||||
|
manualRecursive: false,
|
||||||
} as WebdavConfig;
|
} as WebdavConfig;
|
||||||
|
|
||||||
const getWebdavPath = (fileOrFolderPath: string, vaultName: string) => {
|
const getWebdavPath = (fileOrFolderPath: string, vaultName: string) => {
|
||||||
@@ -89,7 +89,7 @@ export class WrappedWebdavClient {
|
|||||||
: AuthType.Password,
|
: AuthType.Password,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log("no password");
|
log.info("no password");
|
||||||
this.client = createClient(this.webdavConfig.address);
|
this.client = createClient(this.webdavConfig.address);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,12 +100,12 @@ export class WrappedWebdavClient {
|
|||||||
} else {
|
} else {
|
||||||
const res = await this.client.exists(`/${this.vaultName}`);
|
const res = await this.client.exists(`/${this.vaultName}`);
|
||||||
if (res) {
|
if (res) {
|
||||||
console.log("exits!");
|
// log.info("remote vault folder exits!");
|
||||||
this.vaultFolderExists = true;
|
this.vaultFolderExists = true;
|
||||||
} else {
|
} else {
|
||||||
console.log("not exists, creating");
|
log.info("remote vault folder not exists, creating");
|
||||||
await this.client.createDirectory(`/${this.vaultName}`);
|
await this.client.createDirectory(`/${this.vaultName}`);
|
||||||
console.log("created!");
|
log.info("remote vault folder created!");
|
||||||
this.vaultFolderExists = true;
|
this.vaultFolderExists = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ export const getRemoteMeta = async (
|
|||||||
) => {
|
) => {
|
||||||
await client.init();
|
await client.init();
|
||||||
const remotePath = getWebdavPath(fileOrFolderPath, client.vaultName);
|
const remotePath = getWebdavPath(fileOrFolderPath, client.vaultName);
|
||||||
console.log(`remotePath = ${remotePath}`);
|
// log.info(`remotePath = ${remotePath}`);
|
||||||
const res = (await client.client.stat(remotePath, {
|
const res = (await client.client.stat(remotePath, {
|
||||||
details: false,
|
details: false,
|
||||||
})) as FileStat;
|
})) as FileStat;
|
||||||
@@ -165,7 +165,7 @@ export const uploadToRemote = async (
|
|||||||
await client.client.putFileContents(uploadFile, "", {
|
await client.client.putFileContents(uploadFile, "", {
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
onUploadProgress: (progress) => {
|
onUploadProgress: (progress) => {
|
||||||
console.log(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
// log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ export const uploadToRemote = async (
|
|||||||
await client.client.putFileContents(uploadFile, remoteContent, {
|
await client.client.putFileContents(uploadFile, remoteContent, {
|
||||||
overwrite: true,
|
overwrite: true,
|
||||||
onUploadProgress: (progress) => {
|
onUploadProgress: (progress) => {
|
||||||
console.log(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,7 +203,43 @@ export const listFromRemote = async (
|
|||||||
throw Error("prefix not supported");
|
throw Error("prefix not supported");
|
||||||
}
|
}
|
||||||
await client.init();
|
await client.init();
|
||||||
const contents = (await client.client.getDirectoryContents(
|
|
||||||
|
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}`,
|
`/${client.vaultName}`,
|
||||||
{
|
{
|
||||||
deep: true,
|
deep: true,
|
||||||
@@ -211,6 +247,7 @@ export const listFromRemote = async (
|
|||||||
glob: "/**" /* avoid dot files by using glob */,
|
glob: "/**" /* avoid dot files by using glob */,
|
||||||
}
|
}
|
||||||
)) as FileStat[];
|
)) as FileStat[];
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
Contents: contents.map((x) =>
|
Contents: contents.map((x) =>
|
||||||
fromWebdavItemToRemoteItem(x, client.vaultName)
|
fromWebdavItemToRemoteItem(x, client.vaultName)
|
||||||
@@ -289,16 +326,16 @@ export const deleteFromRemote = async (
|
|||||||
await client.init();
|
await client.init();
|
||||||
try {
|
try {
|
||||||
await client.client.deleteFile(remoteFileName);
|
await client.client.deleteFile(remoteFileName);
|
||||||
// console.log(`delete ${remoteFileName} succeeded`);
|
// log.info(`delete ${remoteFileName} succeeded`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("some error while deleting");
|
console.error("some error while deleting");
|
||||||
console.log(err);
|
log.info(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const checkConnectivity = async (client: WrappedWebdavClient) => {
|
export const checkConnectivity = async (client: WrappedWebdavClient) => {
|
||||||
await client.init();
|
|
||||||
try {
|
try {
|
||||||
|
await client.init();
|
||||||
const results = await getRemoteMeta(client, "/");
|
const results = await getRemoteMeta(client, "/");
|
||||||
if (results === undefined) {
|
if (results === undefined) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
+1092
File diff suppressed because it is too large
Load Diff
+104
-29
@@ -1,20 +1,23 @@
|
|||||||
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
import { TAbstractFile, TFile, TFolder, Vault } from "obsidian";
|
||||||
|
import type { RemoteItem, SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
||||||
import {
|
|
||||||
clearDeleteRenameHistoryOfKey,
|
|
||||||
upsertSyncMetaMappingData,
|
|
||||||
getSyncMetaMappingByRemoteKey,
|
|
||||||
} from "./localdb";
|
|
||||||
import type { FileFolderHistoryRecord, InternalDBs } from "./localdb";
|
|
||||||
|
|
||||||
import { RemoteClient } from "./remote";
|
|
||||||
import type { SUPPORTED_SERVICES_TYPE, RemoteItem } from "./baseTypes";
|
|
||||||
import { mkdirpInVault, isHiddenPath, isVaildText, setToString } from "./misc";
|
|
||||||
import {
|
import {
|
||||||
decryptBase32ToString,
|
decryptBase32ToString,
|
||||||
encryptStringToBase32,
|
decryptBase64urlToString,
|
||||||
|
encryptStringToBase64url,
|
||||||
MAGIC_ENCRYPTED_PREFIX_BASE32,
|
MAGIC_ENCRYPTED_PREFIX_BASE32,
|
||||||
|
MAGIC_ENCRYPTED_PREFIX_BASE64URL,
|
||||||
} from "./encrypt";
|
} 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 =
|
export type SyncStatusType =
|
||||||
| "idle"
|
| "idle"
|
||||||
@@ -85,7 +88,7 @@ export const isPasswordOk = async (
|
|||||||
}
|
}
|
||||||
const santyCheckKey = remote[0].key;
|
const santyCheckKey = remote[0].key;
|
||||||
if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) {
|
if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) {
|
||||||
// this is encrypted!
|
// this is encrypted using old base32!
|
||||||
// try to decrypt it using the provided password.
|
// try to decrypt it using the provided password.
|
||||||
if (password === "") {
|
if (password === "") {
|
||||||
return {
|
return {
|
||||||
@@ -96,6 +99,38 @@ export const isPasswordOk = async (
|
|||||||
try {
|
try {
|
||||||
const res = await decryptBase32ToString(santyCheckKey, password);
|
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
|
// additional test
|
||||||
// because iOS Safari bypasses decryption with wrong password!
|
// because iOS Safari bypasses decryption with wrong password!
|
||||||
if (isVaildText(res)) {
|
if (isVaildText(res)) {
|
||||||
@@ -135,6 +170,7 @@ const ensembleMixedStates = async (
|
|||||||
local: TAbstractFile[],
|
local: TAbstractFile[],
|
||||||
deleteHistory: FileFolderHistoryRecord[],
|
deleteHistory: FileFolderHistoryRecord[],
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
|
vaultRandomID: string,
|
||||||
remoteType: SUPPORTED_SERVICES_TYPE,
|
remoteType: SUPPORTED_SERVICES_TYPE,
|
||||||
password: string = ""
|
password: string = ""
|
||||||
) => {
|
) => {
|
||||||
@@ -145,14 +181,23 @@ const ensembleMixedStates = async (
|
|||||||
const remoteEncryptedKey = entry.key;
|
const remoteEncryptedKey = entry.key;
|
||||||
let key = remoteEncryptedKey;
|
let key = remoteEncryptedKey;
|
||||||
if (password !== "") {
|
if (password !== "") {
|
||||||
|
if (remoteEncryptedKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) {
|
||||||
key = await decryptBase32ToString(remoteEncryptedKey, password);
|
key = await decryptBase32ToString(remoteEncryptedKey, password);
|
||||||
|
} else if (
|
||||||
|
remoteEncryptedKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE64URL)
|
||||||
|
) {
|
||||||
|
key = await decryptBase64urlToString(remoteEncryptedKey, password);
|
||||||
|
} else {
|
||||||
|
throw Error(`unexpected key=${remoteEncryptedKey}`);
|
||||||
}
|
}
|
||||||
const backwardMapping = await getSyncMetaMappingByRemoteKey(
|
}
|
||||||
|
const backwardMapping = await getSyncMetaMappingByRemoteKeyAndVault(
|
||||||
remoteType,
|
remoteType,
|
||||||
db,
|
db,
|
||||||
key,
|
key,
|
||||||
entry.lastModified,
|
entry.lastModified,
|
||||||
entry.etag
|
entry.etag,
|
||||||
|
vaultRandomID
|
||||||
);
|
);
|
||||||
|
|
||||||
let r = {} as FileOrFolderMixedState;
|
let r = {} as FileOrFolderMixedState;
|
||||||
@@ -400,6 +445,7 @@ export const getSyncPlan = async (
|
|||||||
local: TAbstractFile[],
|
local: TAbstractFile[],
|
||||||
deleteHistory: FileFolderHistoryRecord[],
|
deleteHistory: FileFolderHistoryRecord[],
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
|
vaultRandomID: string,
|
||||||
remoteType: SUPPORTED_SERVICES_TYPE,
|
remoteType: SUPPORTED_SERVICES_TYPE,
|
||||||
password: string = ""
|
password: string = ""
|
||||||
) => {
|
) => {
|
||||||
@@ -408,6 +454,7 @@ export const getSyncPlan = async (
|
|||||||
local,
|
local,
|
||||||
deleteHistory,
|
deleteHistory,
|
||||||
db,
|
db,
|
||||||
|
vaultRandomID,
|
||||||
remoteType,
|
remoteType,
|
||||||
password
|
password
|
||||||
);
|
);
|
||||||
@@ -424,6 +471,7 @@ export const getSyncPlan = async (
|
|||||||
|
|
||||||
const dispatchOperationToActual = async (
|
const dispatchOperationToActual = async (
|
||||||
key: string,
|
key: string,
|
||||||
|
vaultRandomID: string,
|
||||||
state: FileOrFolderMixedState,
|
state: FileOrFolderMixedState,
|
||||||
client: RemoteClient,
|
client: RemoteClient,
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
@@ -435,7 +483,10 @@ const dispatchOperationToActual = async (
|
|||||||
if (password !== "") {
|
if (password !== "") {
|
||||||
remoteEncryptedKey = state.remote_encrypted_key;
|
remoteEncryptedKey = state.remote_encrypted_key;
|
||||||
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||||
remoteEncryptedKey = await encryptStringToBase32(key, password);
|
// the old version uses base32
|
||||||
|
// remoteEncryptedKey = await encryptStringToBase32(key, password);
|
||||||
|
// the new version users base64url
|
||||||
|
remoteEncryptedKey = await encryptStringToBase64url(key, password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +498,18 @@ const dispatchOperationToActual = async (
|
|||||||
throw Error(`unknown decision in ${JSON.stringify(state)}`);
|
throw Error(`unknown decision in ${JSON.stringify(state)}`);
|
||||||
} else if (state.decision === "skip") {
|
} else if (state.decision === "skip") {
|
||||||
// do nothing
|
// 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") {
|
} else if (state.decision === "download_clearhist") {
|
||||||
await client.downloadFromRemote(
|
await client.downloadFromRemote(
|
||||||
state.key,
|
state.key,
|
||||||
@@ -455,7 +518,7 @@ const dispatchOperationToActual = async (
|
|||||||
password,
|
password,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey
|
||||||
);
|
);
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
await clearDeleteRenameHistoryOfKeyAndVault(db, state.key, vaultRandomID);
|
||||||
} else if (state.decision === "upload_clearhist") {
|
} else if (state.decision === "upload_clearhist") {
|
||||||
const remoteObjMeta = await client.uploadToRemote(
|
const remoteObjMeta = await client.uploadToRemote(
|
||||||
state.key,
|
state.key,
|
||||||
@@ -465,7 +528,7 @@ const dispatchOperationToActual = async (
|
|||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
foldersCreatedBefore
|
foldersCreatedBefore
|
||||||
);
|
);
|
||||||
await upsertSyncMetaMappingData(
|
await upsertSyncMetaMappingDataByVault(
|
||||||
client.serviceType,
|
client.serviceType,
|
||||||
db,
|
db,
|
||||||
state.key,
|
state.key,
|
||||||
@@ -474,9 +537,10 @@ const dispatchOperationToActual = async (
|
|||||||
state.key,
|
state.key,
|
||||||
remoteObjMeta.lastModified,
|
remoteObjMeta.lastModified,
|
||||||
remoteObjMeta.size,
|
remoteObjMeta.size,
|
||||||
remoteObjMeta.etag
|
remoteObjMeta.etag,
|
||||||
|
vaultRandomID
|
||||||
);
|
);
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
await clearDeleteRenameHistoryOfKeyAndVault(db, state.key, vaultRandomID);
|
||||||
} else if (state.decision === "download") {
|
} else if (state.decision === "download") {
|
||||||
await mkdirpInVault(state.key, vault);
|
await mkdirpInVault(state.key, vault);
|
||||||
await client.downloadFromRemote(
|
await client.downloadFromRemote(
|
||||||
@@ -488,7 +552,7 @@ const dispatchOperationToActual = async (
|
|||||||
);
|
);
|
||||||
} else if (state.decision === "delremote_clearhist") {
|
} else if (state.decision === "delremote_clearhist") {
|
||||||
await client.deleteFromRemote(state.key, password, remoteEncryptedKey);
|
await client.deleteFromRemote(state.key, password, remoteEncryptedKey);
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
await clearDeleteRenameHistoryOfKeyAndVault(db, state.key, vaultRandomID);
|
||||||
} else if (state.decision === "upload") {
|
} else if (state.decision === "upload") {
|
||||||
const remoteObjMeta = await client.uploadToRemote(
|
const remoteObjMeta = await client.uploadToRemote(
|
||||||
state.key,
|
state.key,
|
||||||
@@ -498,7 +562,7 @@ const dispatchOperationToActual = async (
|
|||||||
remoteEncryptedKey,
|
remoteEncryptedKey,
|
||||||
foldersCreatedBefore
|
foldersCreatedBefore
|
||||||
);
|
);
|
||||||
await upsertSyncMetaMappingData(
|
await upsertSyncMetaMappingDataByVault(
|
||||||
client.serviceType,
|
client.serviceType,
|
||||||
db,
|
db,
|
||||||
state.key,
|
state.key,
|
||||||
@@ -507,10 +571,11 @@ const dispatchOperationToActual = async (
|
|||||||
state.key,
|
state.key,
|
||||||
remoteObjMeta.lastModified,
|
remoteObjMeta.lastModified,
|
||||||
remoteObjMeta.size,
|
remoteObjMeta.size,
|
||||||
remoteObjMeta.etag
|
remoteObjMeta.etag,
|
||||||
|
vaultRandomID
|
||||||
);
|
);
|
||||||
} else if (state.decision === "clearhist") {
|
} else if (state.decision === "clearhist") {
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
await clearDeleteRenameHistoryOfKeyAndVault(db, state.key, vaultRandomID);
|
||||||
} else {
|
} else {
|
||||||
throw Error("this should never happen!");
|
throw Error("this should never happen!");
|
||||||
}
|
}
|
||||||
@@ -519,27 +584,37 @@ const dispatchOperationToActual = async (
|
|||||||
export const doActualSync = async (
|
export const doActualSync = async (
|
||||||
client: RemoteClient,
|
client: RemoteClient,
|
||||||
db: InternalDBs,
|
db: InternalDBs,
|
||||||
|
vaultRandomID: string,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
syncPlan: SyncPlanType,
|
syncPlan: SyncPlanType,
|
||||||
password: string = ""
|
password: string = "",
|
||||||
|
callbackSyncProcess?: any
|
||||||
) => {
|
) => {
|
||||||
const keyStates = syncPlan.mixedStates;
|
const keyStates = syncPlan.mixedStates;
|
||||||
const foldersCreatedBefore = new Set<string>();
|
const foldersCreatedBefore = new Set<string>();
|
||||||
|
let i = 0;
|
||||||
|
const totalCount = Object.keys(keyStates).length || 0;
|
||||||
for (const [k, v] of Object.entries(keyStates).sort(
|
for (const [k, v] of Object.entries(keyStates).sort(
|
||||||
([k1, v1], [k2, v2]) => k2.length - k1.length
|
([k1, v1], [k2, v2]) => k2.length - k1.length
|
||||||
)) {
|
)) {
|
||||||
|
i += 1;
|
||||||
const k2 = k as string;
|
const k2 = k as string;
|
||||||
const v2 = v as FileOrFolderMixedState;
|
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(
|
await dispatchOperationToActual(
|
||||||
k as string,
|
k2,
|
||||||
v as FileOrFolderMixedState,
|
vaultRandomID,
|
||||||
|
v2,
|
||||||
client,
|
client,
|
||||||
db,
|
db,
|
||||||
vault,
|
vault,
|
||||||
password,
|
password,
|
||||||
foldersCreatedBefore
|
foldersCreatedBefore
|
||||||
);
|
);
|
||||||
// console.log(`finished ${k}, with ${setToString(foldersCreatedBefore)}`);
|
log.info(`finished ${k2}`);
|
||||||
}
|
}
|
||||||
// await Promise.all(
|
// await Promise.all(
|
||||||
// Object.entries(keyStates)
|
// Object.entries(keyStates)
|
||||||
|
|||||||
+15
@@ -30,6 +30,21 @@
|
|||||||
display: none;
|
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 {
|
.webdav-disclaimer {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import * as chai from "chai";
|
import * as chai from "chai";
|
||||||
import chaiAsPromised from "chai-as-promised";
|
import chaiAsPromised from "chai-as-promised";
|
||||||
import { base64ToBase32, bufferToArrayBuffer } from "../src/misc";
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
import {
|
import {
|
||||||
decryptArrayBuffer,
|
decryptArrayBuffer,
|
||||||
decryptBase32ToString,
|
decryptBase32ToString,
|
||||||
encryptArrayBuffer,
|
encryptArrayBuffer,
|
||||||
encryptStringToBase32,
|
encryptStringToBase32,
|
||||||
|
encryptStringToBase64url,
|
||||||
} from "../src/encrypt";
|
} from "../src/encrypt";
|
||||||
|
import { base64ToBase64url, bufferToArrayBuffer } from "../src/misc";
|
||||||
|
|
||||||
chai.use(chaiAsPromised);
|
chai.use(chaiAsPromised);
|
||||||
const expect = chai.expect;
|
const expect = chai.expect;
|
||||||
@@ -52,7 +53,7 @@ describe("Encryption tests", () => {
|
|||||||
).toString("utf-8");
|
).toString("utf-8");
|
||||||
const password = "somepassword";
|
const password = "somepassword";
|
||||||
const saltHex = "8302F586FAB491EC";
|
const saltHex = "8302F586FAB491EC";
|
||||||
const enc = await encryptStringToBase32(
|
const enc = await encryptStringToBase64url(
|
||||||
fileContent,
|
fileContent,
|
||||||
password,
|
password,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -65,9 +66,9 @@ describe("Encryption tests", () => {
|
|||||||
const opensslBase64Res =
|
const opensslBase64Res =
|
||||||
"U2FsdGVkX1+DAvWG+rSR7BPXMnlvSSVGMdjsx7kE1CTH+28P+yAZRdDGgFWMGkMd";
|
"U2FsdGVkX1+DAvWG+rSR7BPXMnlvSSVGMdjsx7kE1CTH+28P+yAZRdDGgFWMGkMd";
|
||||||
// we output base32, so we need some transformation
|
// 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 () => {
|
it("should encrypt binary file and get the same result as openssl", async () => {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import * as fs from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
import { expect } from "chai";
|
import { expect } from "chai";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
import * as misc from "../src/misc";
|
import * as misc from "../src/misc";
|
||||||
|
|
||||||
describe("Misc: hidden file", () => {
|
describe("Misc: hidden file", () => {
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"0.2.4": "0.12.15"
|
"0.2.14": "0.12.15"
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -4,6 +4,8 @@ const webpack = require("webpack");
|
|||||||
const TerserPlugin = require("terser-webpack-plugin");
|
const TerserPlugin = require("terser-webpack-plugin");
|
||||||
|
|
||||||
const DEFAULT_DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY || "";
|
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 = {
|
module.exports = {
|
||||||
entry: "./src/main.ts",
|
entry: "./src/main.ts",
|
||||||
@@ -16,6 +18,8 @@ module.exports = {
|
|||||||
plugins: [
|
plugins: [
|
||||||
new webpack.DefinePlugin({
|
new webpack.DefinePlugin({
|
||||||
"process.env.DEFAULT_DROPBOX_APP_KEY": `"${DEFAULT_DROPBOX_APP_KEY}"`,
|
"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:
|
// Work around for Buffer is undefined:
|
||||||
// https://github.com/webpack/changelog-v5/issues/10
|
// https://github.com/webpack/changelog-v5/issues/10
|
||||||
@@ -47,8 +51,8 @@ module.exports = {
|
|||||||
// buffer: require.resolve("buffer/"),
|
// buffer: require.resolve("buffer/"),
|
||||||
// console: require.resolve("console-browserify"),
|
// console: require.resolve("console-browserify"),
|
||||||
// constants: require.resolve("constants-browserify"),
|
// constants: require.resolve("constants-browserify"),
|
||||||
// crypto: require.resolve("crypto-browserify"),
|
crypto: require.resolve("crypto-browserify"),
|
||||||
crypto: false,
|
// crypto: false,
|
||||||
// domain: require.resolve("domain-browser"),
|
// domain: require.resolve("domain-browser"),
|
||||||
// events: require.resolve("events"),
|
// events: require.resolve("events"),
|
||||||
// http: require.resolve("stream-http"),
|
// http: require.resolve("stream-http"),
|
||||||
|
|||||||
Reference in New Issue
Block a user