Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2576354269 | ||
|
|
122fec1d75 | ||
|
|
27477f8d97 | ||
|
|
e9890c3c79 | ||
|
|
44480be707 | ||
|
|
437e041e71 | ||
|
|
9e4fed4504 | ||
|
|
70e2884f89 | ||
|
|
fb63a38fa0 | ||
|
|
d21d90868a | ||
|
|
e6591721e2 | ||
|
|
b6bae792c6 | ||
|
|
8624141780 | ||
|
|
563c2c3ff6 | ||
|
|
9a72ac8d66 | ||
|
|
d980bcbc1b | ||
|
|
6ebe1a8966 | ||
|
|
d0ab1d8abe | ||
|
|
b86d116830 | ||
|
|
7d5eaad36f | ||
|
|
7a13f4566c | ||
|
|
d355f72b7a | ||
|
|
9b1eebdf90 | ||
|
|
2ce461ed6a | ||
|
|
3aa91a2a1e | ||
|
|
2a3e0524c5 | ||
|
|
2f20ca61ce | ||
|
|
68ec8b5454 | ||
|
|
2bbbd569c5 | ||
|
|
62936c4473 | ||
|
|
789d07261e | ||
|
|
ccd3b49b4b | ||
|
|
2930fe5350 | ||
|
|
a9abf6d834 | ||
|
|
bdf09e24c3 | ||
|
|
8dc4b835c9 | ||
|
|
2e800af4f8 | ||
|
|
e9e9e2b6eb | ||
|
|
274e0b8b28 | ||
|
|
2147332cf4 | ||
|
|
262bb9a1fe | ||
|
|
2669a3e8ad | ||
|
|
bdac16ee24 | ||
|
|
0a7de6588a | ||
|
|
1effcdd7f7 | ||
|
|
00c840a758 | ||
|
|
2eb1545f9d | ||
|
|
0d75ea92b1 | ||
|
|
d6cbf4260a | ||
|
|
9389039c7a | ||
|
|
aa3aecb074 | ||
|
|
9128f9ed5b | ||
|
|
b581ef3f31 | ||
|
|
d8c42adc55 | ||
|
|
45b18ca63d | ||
|
|
52e9d3298a | ||
|
|
e82dfc1e88 | ||
|
|
0be19b677b | ||
|
|
d1839706af | ||
|
|
ce0cc232c8 | ||
|
|
12d09a30ef | ||
|
|
3fdc443ecf | ||
|
|
0700af855d | ||
|
|
d3b81ca3dc | ||
|
|
2a572fb1bd | ||
|
|
76f90ea375 | ||
|
|
d753617628 | ||
|
|
69c9686ce8 | ||
|
|
b3e3022751 | ||
|
|
2ad342c201 | ||
|
|
2182c6e886 | ||
|
|
0d9cbd176c | ||
|
|
d944260a97 | ||
|
|
14f305adda | ||
|
|
6459dcd228 | ||
|
|
714807cdc7 | ||
|
|
59645e620a | ||
|
|
125da9d734 | ||
|
|
808ae709fb | ||
|
|
16c3c11fd8 | ||
|
|
be2b168c6c | ||
|
|
6b296d6110 | ||
|
|
6757348ac9 | ||
|
|
a7e6bed071 | ||
|
|
8b8c5bdb98 | ||
|
|
5b77b23e9f | ||
|
|
05313f9530 | ||
|
|
ce4e55fcc7 | ||
|
|
b26d6a7ffb | ||
|
|
ea12a14d7f | ||
|
|
3609a78465 | ||
|
|
7ec2db4006 | ||
|
|
5352c7b828 | ||
|
|
2a49bb53b2 | ||
|
|
fde4c9a294 | ||
|
|
7fc38a942a | ||
|
|
cc0c4fe224 | ||
|
|
2ab5640c12 | ||
|
|
b7a0376a0f | ||
|
|
d7d1c47e2a | ||
|
|
a65f5eb004 | ||
|
|
2ce34379ad | ||
|
|
40d2ee95b7 | ||
|
|
5632e28331 | ||
|
|
2b53a1bf5e | ||
|
|
ccd9e3116c | ||
|
|
e8d6a50793 | ||
|
|
c43b36e0e7 | ||
|
|
bcd92d135f | ||
|
|
62aea9d330 | ||
|
|
b9c17076b4 | ||
|
|
385290c1cc | ||
|
|
371089e3ee | ||
|
|
254790cd61 | ||
|
|
d3b812c8fa | ||
|
|
3a14f86880 | ||
|
|
887f948dc3 | ||
|
|
035beb7d97 | ||
|
|
f334198241 | ||
|
|
bcca2b4446 | ||
|
|
659deb9830 | ||
|
|
b6df3f7ed5 | ||
|
|
2012758383 | ||
|
|
bea0e694f6 | ||
|
|
8fbbae6c2e | ||
|
|
ec2fec9bf8 | ||
|
|
09d33523b6 | ||
|
|
170cbbfb4f | ||
|
|
9d5fb7e966 | ||
|
|
dc11d21d72 | ||
|
|
774e31ef27 | ||
|
|
7c4a981434 | ||
|
|
358311e8ac | ||
|
|
2551462003 | ||
|
|
60d5b78df0 | ||
|
|
f04017e05b | ||
|
|
cb0098ec85 | ||
|
|
d007d5f230 | ||
|
|
07c6fbe747 | ||
|
|
5e3d413b33 | ||
|
|
bcf8586fbf | ||
|
|
6431182ec3 |
@@ -0,0 +1,7 @@
|
|||||||
|
*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pdf filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.enc filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.svg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.excalidraw filter=lfs diff=lfs merge=lfs -text
|
||||||
@@ -5,26 +5,34 @@ name: BuildCI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master, enc]
|
branches: [master]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, enc]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
environment: env-for-buildci
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [16.x]
|
node-version: [16.x]
|
||||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- name: Checkout codes
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
- name: Checkout LFS
|
||||||
|
run: git lfs checkout
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
- run: npm install
|
- run: npm install
|
||||||
|
- run: npm test
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v2
|
||||||
with:
|
with:
|
||||||
@@ -32,3 +40,4 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
main.js
|
main.js
|
||||||
manifest.json
|
manifest.json
|
||||||
|
styles.css
|
||||||
|
|||||||
@@ -13,5 +13,8 @@ main.js
|
|||||||
# obsidian
|
# obsidian
|
||||||
data.json
|
data.json
|
||||||
|
|
||||||
|
# debug
|
||||||
|
logs.txt
|
||||||
|
|
||||||
# hidden files
|
# hidden files
|
||||||
.*
|
.*
|
||||||
|
|||||||
@@ -1,18 +1,112 @@
|
|||||||
# Save Remote
|
# Remotely Save
|
||||||
|
|
||||||
This is yet another sync plugin for Obsidian.
|
This is yet another unofficial sync plugin for Obsidian. If you like it or find it useful, please consider give it a [star ](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) [](https://github.com/fyears/remotely-save/releases)
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
**This is NOT the official sync service provided by Obsidian.**
|
- **This is NOT the [official sync service](https://obsidian.md/sync) provided by Obsidian.**
|
||||||
|
|
||||||
## !!!Caution!!!
|
## !!!Caution!!!
|
||||||
|
|
||||||
As of October 2021, the plugin is under development. **DO NOT USE IT for any serious vaults.** Prepare for data loss!
|
As of Jan 2022, the plugin is considered in BETA stage. **DO NOT USE IT for any serious vaults.** **ALWAYS, ALWAYS, backup your vault before using this plugin.**
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Supports:
|
||||||
|
- Amazon S3 or S3-compatible
|
||||||
|
- Dropbox
|
||||||
|
- OneDrive for personal
|
||||||
|
- Webdav
|
||||||
|
- **Obsidiain Mobile supported.** Vaults can be synced across mobile and desktop devices with the cloud service as the "broker".
|
||||||
|
- **[End-to-end encryption](./docs/encryption.md) supported.** Files would be encrypted using openssl format before being sent to the cloud **if** user specify a password.
|
||||||
|
- **[Minimal Intrusive](./docs/minimal_intrusive_design.md).**
|
||||||
|
- **Fully open source under [Apache-2.0 License](./LICENSE).**
|
||||||
|
- **[Sync Algorithm open](./docs/sync_algorithm.md) for discussion.**
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **No Conflict resolution. No content-diff-and-patch algorithm.** All files and folders are compared using their local and remote "last modified time" and those with later "last modified time" wins.
|
||||||
|
- **Cloud services cost you money.** Always be aware of the costs and pricing.
|
||||||
|
- **All files or folder starting with `.` (dot) or `_` (underscore) are treated as hidden files, and would NOT be synced.** It's useful if you have some files just staying locally. But this strategy also means that themes / other plugins / settings of this plugin would neither be synced.
|
||||||
|
|
||||||
|
## Questions, Suggestions, Or Bugs
|
||||||
|
|
||||||
|
You are greatly welcome to ask questions, post any suggestions, or report any bugs! The project is mainly maintained on GitHub:
|
||||||
|
|
||||||
|
- Questions: [GitHub repo Discussions](https://github.com/fyears/remotely-save/discussions)
|
||||||
|
- Suggestions: also in [GitHub repo Discussions](https://github.com/fyears/remotely-save/discussions)
|
||||||
|
- Bugs: [GitHub repo Issues](https://github.com/fyears/remotely-save/issues) (NOT Discussion)
|
||||||
|
|
||||||
|
Additionally, the plugin author may occasionally visit Obsidian official forum and official Discord server, and pay attention to this-plugin-related information there.
|
||||||
|
|
||||||
|
## Download and Install
|
||||||
|
|
||||||
|
- Option #1: You can search, download, and install the plugin in official "community plugin list".
|
||||||
|
- 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: [](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
|
||||||
|
|
||||||
Remote service in support list:
|
### S3
|
||||||
|
|
||||||
- [ ] AWS S3
|
- Prepare your S3 (-compatible) service information: [endpoint, region](https://docs.aws.amazon.com/general/latest/gr/s3.html), [access key id, secret access key](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html), bucket name. The bucket should be empty and solely for syncing a vault.
|
||||||
- [ ] webdav
|
- Configure (enable) [CORS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html) for requests from `app://obsidian.md` and `capacitor://localhost` and `http://localhost`. It's unfortunately required, because the plugin sends requests from a browser-like envirement. And those addresses are tested and found on desktop and ios and android.
|
||||||
|
- Download and enable this plugin.
|
||||||
|
- Enter your information to the settings of this plugin.
|
||||||
|
- If you want to enable end-to-end encryption, also set a password in settings. If you do not specify a password, the files and folders are synced in plain, original content to the cloud.
|
||||||
|
- Click the new "switch" icon on the ribbon (the left sidebar), **every time** you want to sync your vault between local and remote. (No "auto sync" yet.)
|
||||||
|
- **Be patient while syncing.** Especially in the first-time sync.
|
||||||
|
|
||||||
|
### Dropbox
|
||||||
|
|
||||||
|
- **This plugin's function for Dropbox is not as mature as functions for S3.**
|
||||||
|
- **This plugin is NOT an official Dropbox product.** The plugin just uses Dropbox's public API.
|
||||||
|
- After the authorization, the plugin can read your name and email (which cannot be unselected on Dropbox api), and read and write files in your Dropbox's `/Apps/remotely-save` folder.
|
||||||
|
- If you decide to authorize this plugin to connect to Dropbox, please go to plugin's settings, and choose Dropbox then follow the instructions.
|
||||||
|
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
||||||
|
|
||||||
|
### OneDrive for personal
|
||||||
|
|
||||||
|
- **This plugin's function for OneDrive is not as mature as functions for S3.**
|
||||||
|
- **This plugin is NOT an official Microsoft / OneDrive product.** The plugin just uses Microsoft's [OneDrive's public API](https://docs.microsoft.com/en-us/onedrive/developer/rest-api).
|
||||||
|
- This plugin only works for "OneDrive for personal", and not works for "OneDrive for Business" (yet). See [#11](https://github.com/fyears/remotely-save/issues/11) to further details.
|
||||||
|
- After the authorization, the plugin can read your name and email, and read and write files in your OneDrive's `/Apps/remotely-save` folder.
|
||||||
|
- If you decide to authorize this plugin to connect to OneDrive, please go to plugin's settings, and choose OneDrive then follow the instructions.
|
||||||
|
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
||||||
|
|
||||||
|
### webdav
|
||||||
|
|
||||||
|
- **webdav support is considered experimental.**
|
||||||
|
- Currently only supports BASIC authorization method.
|
||||||
|
- Currently webdav server has to be enabled CORS for requests from `app://obsidian.md` and `capacitor://localhost` and `http://localhost`, **AND** all webdav HTTP methods, **AND** all webdav headers. These are required, because Obsidian mobile works like a browser and mobile plugins are limited by CORS policies.
|
||||||
|
- Popular software NextCloud, OwnCloud, `rclone serve webdav` do **NOT** enable CORS by default. If you are using any of them, you should evaluate the risk, and find a way to enable CORS, before using this plugin.
|
||||||
|
- The plugin is tested successfully under python package [`wsgidav` (version 4.0)](https://github.com/mar10/wsgidav). See [this issue](https://github.com/mar10/wsgidav/issues/239) for some details.
|
||||||
|
- Your data would be synced to a `${vaultName}` sub folder on your webdav server.
|
||||||
|
- Password-based end-to-end encryption is also supported. But please be aware that **the vault name itself is not encrypted**.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6b33b2a71f2d91988416c17015b59ef205dbf6a8c704ecac1cc95ed03ccc2c8e
|
||||||
|
size 16537
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:98358aa726dcb5565164ff260634f5c527223d1f2ec1d0410b400625c8dad704
|
||||||
|
size 14082
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:02a5057ebe38e4c3835c8354f53aca01c4c2335d8a0435e22f509b4ca4a73ca4
|
||||||
|
size 12889
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# Algorithm
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
We have three record sources:
|
|
||||||
|
|
||||||
1. Local files
|
|
||||||
2. Remote files
|
|
||||||
3. Local "delete-or-rename" history.
|
|
||||||
|
|
||||||
Assuming all sources are reliable.
|
|
||||||
|
|
||||||
## Deal with them
|
|
||||||
|
|
||||||
We list all combinations mutually exclusive and collectively exhaustive.
|
|
||||||
|
|
||||||
| ID | Remote Files | Local files | Local delete rename history | Extra | Decision |
|
|
||||||
| --- | ------------ | ----------- | --------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
||||||
| 1 | exist | exist | ignore | mtime_remote > mtime_local | download remote file, create local folder if not exists, clear local history if exists |
|
|
||||||
| 2 | exist | exist | ignore | mtime_remote < mtime_local | upload local file, create remote folder if not exists, clear local history if exists |
|
|
||||||
| 3 | exist | exist | ignore | mtime_remote === mtime_local && size_remote === size_local | clear local history if exists (the file was synced and no changes after last sync) |
|
|
||||||
| 4 | exist | exist | ignore | mtime_remote === mtime_local && size_remote !== size_local | upload local file, clear local history if exists (we always prefer local to remote) |
|
|
||||||
| 5 | exist | exist | ignore | If local is a folder. mtime_local === undefined | clear local history if exists. TODO: what if a folder and a previous file share the same name? |
|
|
||||||
| 6 | exist | not exist | exist | mtime_remote >= delete_time_local | download remote file, create folder if not exists |
|
|
||||||
| 7 | exist | not exist | exist | mtime_remote < delete_time_local | delete remote file, clear local history |
|
|
||||||
| 8 | exist | not exist | not exist | | download remote file, create folder if not exists |
|
|
||||||
| 9 | not exist | exist | ignore | If local is a single file. | upload local file, create remote folder if not exists, clear local history if exists |
|
|
||||||
| 10 | not exist | exist | ignore | If local is a folder. | upload local files recursively, create remote folder if not exists, clear local history if exists |
|
|
||||||
| 11 | not exist | not exist | ignore | | clear local history if exists |
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Encryption
|
||||||
|
|
||||||
|
If a password is set, the files are encrypted before being sent to the cloud.
|
||||||
|
|
||||||
|
The encryption algorithm is delibrately designed to be aligned with openssl format.
|
||||||
|
|
||||||
|
1. The encryption algorithm is implemented using web-crypto.
|
||||||
|
2. The file content is encrypted using openssl format. Assuming a file named `sometext.txt`, a password `somepassword`, then the encryption is equivalent to the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# file content encryption (ignoring file path encryption)
|
||||||
|
openssl enc -p -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:somepassword -in ./sometext.txt -out ./sometext.txt.enc
|
||||||
|
|
||||||
|
# file content decryption (ignoring file path decryption)
|
||||||
|
openssl enc -d -p -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:somepassword -in ./sometext.txt.enc -out ./sometext.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. The file/directory path strings, are encrypted using openssl in binary mode and then `base64url without padding` is applied.
|
||||||
|
Assuming the file path is `a-folder-文件夹/a-file-文件.md`, then the following commands are equivilent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# prepare the functions
|
||||||
|
# https://unix.stackexchange.com/questions/628842
|
||||||
|
base64url::encode () { base64 -w0 | tr '+/' '-_' | tr -d '='; }
|
||||||
|
base64url::decode () { awk '{ if (length($0) % 4 == 3) print $0"="; else if (length($0) % 4 == 2) print $0"=="; else print $0; }' | tr -- '-_' '+/' | base64 -d; }
|
||||||
|
|
||||||
|
# pure string encryption then base32
|
||||||
|
echo -n 'a-folder-文件夹/a-file-文件.md' | openssl enc -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword | base64url::encode
|
||||||
|
|
||||||
|
# pure string base64url then decryption
|
||||||
|
echo -n 'U2FsdGVkX19tNkdFL5rZeHxbe7FL-Pp5mkZJkDNFJWFT6lldZlfa57j0C_cKn0I3PZ9YDvOkyoKqfF6lbn0_yg' | base64url::decode | openssl enc -d -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword
|
||||||
|
```
|
||||||
|
|
||||||
|
4. The directory is considered as special "0-byte" object on remote s3. So this meta infomation may be easily guessed if some third party can access the remote bucket.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Minimal Intrusive Design
|
||||||
|
|
||||||
|
The plugin tries to avoid saving additional meta data remotely.
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
Then the plugin doesn't make any assumptions about information on the remote endpoint.
|
||||||
|
|
||||||
|
For example, it's possbile for a uses to manually upload a file to s3, and next time the plugin can download that file to the local device.
|
||||||
|
|
||||||
|
And it's also possible to combine another "sync-to-s3" solution (like, another software) on desktops, and this plugin on mobile devices, together.
|
||||||
|
|
||||||
|
## Flaws
|
||||||
|
|
||||||
|
The main issue comes from deletions (and renamings which is actually interpreted as "deletion-then-creation").
|
||||||
|
|
||||||
|
Consider this:
|
||||||
|
|
||||||
|
1. The user create and sync a file to the cloud on the 1st device.
|
||||||
|
2. Then download this file to the 2nd device.
|
||||||
|
3. And then delete this file on the 1st device.
|
||||||
|
4. And sync on the 1st device. The file on the cloud is also deleted.
|
||||||
|
5. And sync on the 2nd device. **The 2nd device would upload the file again to the cloud.**
|
||||||
|
|
||||||
|
In step 4, the file is marked "deleted" on the 1st device, and the 1st device send the command "delete this file on the cloud" to the cloud sevice (e.g. s3). Then the file on the cloud is also deleted. So far so good.
|
||||||
|
|
||||||
|
But, in step 5, because no meta data are saved on the cloud, the 2nd device doesn't know that the file are deleted. Instead, it thinks "the file was not synced to the cloud last time, so it's uploaded this time". So an unintentional upload occurs.
|
||||||
|
|
||||||
|
Currently no way to fix this if no meta data are saved remotely. The only workarounds are:
|
||||||
|
|
||||||
|
1. Delete the file on the 1st device, **before** syncing it to the cloud. Then the file never show up on the cloud or on the 2nd device.
|
||||||
|
2. Or, manually delete the file on 2nd device **before** step 5 in above situation.
|
||||||
|
|
||||||
|
## Future
|
||||||
|
|
||||||
|
This design may be changed in the feature, considering the flaws described above.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Sync Algorithm
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
We have three record sources:
|
||||||
|
|
||||||
|
1. Local files
|
||||||
|
2. Remote files
|
||||||
|
3. Local "delete-or-rename" history.
|
||||||
|
|
||||||
|
Assuming all sources are reliable.
|
||||||
|
|
||||||
|
## Deal with them
|
||||||
|
|
||||||
|
We list all combinations mutually exclusive and collectively exhaustive.
|
||||||
|
|
||||||
|
| ID | Remote Files | Local files | Local delete rename history | Extra | Decision |
|
||||||
|
| --- | ------------ | ----------- | --------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | exist | exist | ignore | mtime_remote > mtime_local | download remote file, create local folder if not exists, clear local history if exists |
|
||||||
|
| 2 | exist | exist | ignore | mtime_remote < mtime_local | upload local file, create remote folder if not exists, clear local history if exists |
|
||||||
|
| 3 | exist | exist | ignore | mtime_remote === mtime_local && password === "" && size_remote === size_local | clear local history if exists (the file was synced and no changes after last sync) |
|
||||||
|
| 4 | exist | exist | ignore | mtime_remote === mtime_local && password === "" && size_remote !== size_local | upload local file, clear local history if exists (we always prefer local to remote) |
|
||||||
|
| 5 | exist | exist | ignore | mtime_remote === mtime_local && password !== "" | clear local history if exists (in encryption mode, file sizes are unequal. we can only rely on mtime(s)) |
|
||||||
|
| 6 | exist | exist | ignore | If local is a folder. mtime_local === undefined | clear local history if exists. TODO: what if a folder and a previous file share the same name? |
|
||||||
|
| 7 | exist | not exist | exist | mtime_remote >= delete_time_local | download remote file, create folder if not exists |
|
||||||
|
| 8 | exist | not exist | exist | mtime_remote < delete_time_local | delete remote file, clear local history |
|
||||||
|
| 9 | exist | not exist | not exist | | download remote file, create folder if not exists |
|
||||||
|
| 10 | not exist | exist | ignore | local may be folder or file | upload local files recursively, create remote folder if not exists, clear local history if exists |
|
||||||
|
| 11 | not exist | not exist | ignore | | clear local history if exists |
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import dotenv from "dotenv/config";
|
||||||
|
import esbuild from "esbuild";
|
||||||
|
import process from "process";
|
||||||
|
// import builtins from 'builtin-modules'
|
||||||
|
|
||||||
|
const banner = `/*
|
||||||
|
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||||
|
if you want to view the source, please visit the github repository of this plugin
|
||||||
|
*/
|
||||||
|
`;
|
||||||
|
|
||||||
|
const prod = process.argv[2] === "production";
|
||||||
|
|
||||||
|
const DEFAULT_DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY || "";
|
||||||
|
const DEFAULT_ONEDRIVE_CLIENT_ID = process.env.ONEDRIVE_CLIENT_ID || "";
|
||||||
|
const DEFAULT_ONEDRIVE_AUTHORITY = process.env.ONEDRIVE_AUTHORITY || "";
|
||||||
|
|
||||||
|
esbuild
|
||||||
|
.build({
|
||||||
|
banner: {
|
||||||
|
js: banner,
|
||||||
|
},
|
||||||
|
loader: {
|
||||||
|
".svg": "text",
|
||||||
|
},
|
||||||
|
entryPoints: ["./src/main.ts"],
|
||||||
|
bundle: true,
|
||||||
|
external: [
|
||||||
|
"obsidian",
|
||||||
|
"electron",
|
||||||
|
"fs",
|
||||||
|
"crypto",
|
||||||
|
// ...builtins
|
||||||
|
],
|
||||||
|
format: "cjs",
|
||||||
|
watch: !prod,
|
||||||
|
target: "es2016",
|
||||||
|
logLevel: "info",
|
||||||
|
sourcemap: prod ? false : "inline",
|
||||||
|
treeShaking: true,
|
||||||
|
minify: prod,
|
||||||
|
outfile: "main.js",
|
||||||
|
define: {
|
||||||
|
"process.env.DEFAULT_DROPBOX_APP_KEY": `"${DEFAULT_DROPBOX_APP_KEY}"`,
|
||||||
|
"process.env.DEFAULT_ONEDRIVE_CLIENT_ID": `"${DEFAULT_ONEDRIVE_CLIENT_ID}"`,
|
||||||
|
"process.env.DEFAULT_ONEDRIVE_AUTHORITY": `"${DEFAULT_ONEDRIVE_AUTHORITY}"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => process.exit(1));
|
||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"id": "obsdian-save-remote",
|
"id": "remotely-save",
|
||||||
"name": "Save remote",
|
"name": "Remotely Save",
|
||||||
"version": "0.0.4",
|
"version": "0.2.8",
|
||||||
"minAppVersion": "0.12.15",
|
"minAppVersion": "0.12.15",
|
||||||
"description": "This is yet another plugin allowing users to sync notes between local device and the cloud.",
|
"description": "Yet another unofficial plugin allowing users to synchronize notes between local device and the cloud service.",
|
||||||
"author": "fyears",
|
"author": "fyears",
|
||||||
"authorUrl": "https://github.com/fyears",
|
"authorUrl": "https://github.com/fyears",
|
||||||
"isDesktopOnly": false
|
"isDesktopOnly": false
|
||||||
|
|||||||
+41
-25
@@ -1,63 +1,79 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-save-remote",
|
"name": "remotely-save",
|
||||||
"version": "0.0.4",
|
"version": "0.2.8",
|
||||||
"description": "This is yet another sync plugin for Obsidian app.",
|
"description": "This is yet another sync plugin for Obsidian app.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack --mode development --watch",
|
"dev": "node esbuild.config.mjs",
|
||||||
|
"build2": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"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'"
|
||||||
|
},
|
||||||
|
"browser": {
|
||||||
|
"path": "path-browserify",
|
||||||
|
"process": "process/browser",
|
||||||
|
"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-as-promised": "^7.1.4",
|
||||||
|
"@types/jsdom": "^16.2.13",
|
||||||
|
"@types/lodash": "^4.14.178",
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
|
"@types/mocha": "^9.0.0",
|
||||||
"@types/node": "^14.14.37",
|
"@types/node": "^14.14.37",
|
||||||
|
"@types/qrcode": "^1.4.1",
|
||||||
|
"builtin-modules": "^3.2.0",
|
||||||
|
"chai": "^4.3.4",
|
||||||
|
"chai-as-promised": "^7.1.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"dotenv": "^10.0.0",
|
||||||
|
"esbuild": "^0.14.0",
|
||||||
|
"jsdom": "^19.0.0",
|
||||||
|
"mocha": "^9.1.3",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^2.4.1",
|
||||||
"terser-webpack-plugin": "^5.2.4",
|
|
||||||
"ts-loader": "^9.2.6",
|
"ts-loader": "^9.2.6",
|
||||||
|
"ts-node": "^10.4.0",
|
||||||
"tslib": "^2.2.0",
|
"tslib": "^2.2.0",
|
||||||
"typescript": "^4.4.4",
|
"typescript": "^4.4.4",
|
||||||
"webdav-server": "^2.6.2",
|
"webdav-server": "^2.6.2",
|
||||||
"webpack": "^5.58.2",
|
"webpack": "^5.64.4",
|
||||||
"webpack-cli": "^4.9.1"
|
"webpack-cli": "^4.9.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.37.0",
|
"@aws-sdk/client-s3": "^3.37.0",
|
||||||
|
"@aws-sdk/lib-storage": "^3.40.1",
|
||||||
"@aws-sdk/signature-v4-crt": "^3.37.0",
|
"@aws-sdk/signature-v4-crt": "^3.37.0",
|
||||||
"@types/mime-types": "^2.1.1",
|
"@azure/msal-node": "^1.4.0",
|
||||||
|
"@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",
|
||||||
"browserify-zlib": "^0.2.0",
|
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"codemirror": "^5.63.1",
|
"codemirror": "^5.63.1",
|
||||||
"console-browserify": "^1.2.0",
|
|
||||||
"constants-browserify": "^1.0.0",
|
|
||||||
"crypto-browserify": "^3.12.0",
|
"crypto-browserify": "^3.12.0",
|
||||||
"domain-browser": "^4.22.0",
|
"dropbox": "^10.22.0",
|
||||||
"events": "^3.3.0",
|
"localforage": "^1.10.0",
|
||||||
"hi-base32": "^0.5.1",
|
"lodash": "^4.17.21",
|
||||||
"https-browserify": "^1.0.0",
|
"loglevel": "^1.8.0",
|
||||||
"lovefield-ts": "^0.7.0",
|
|
||||||
"mime-types": "^2.1.33",
|
"mime-types": "^2.1.33",
|
||||||
"obsidian": "^0.12.0",
|
"obsidian": "^0.12.0",
|
||||||
"os-browserify": "^0.3.0",
|
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"punycode": "^2.1.1",
|
"qrcode": "^1.5.0",
|
||||||
"querystring-es3": "^0.2.1",
|
"rfc4648": "^1.5.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"stream-http": "^3.2.0",
|
|
||||||
"string_decoder": "^1.3.0",
|
|
||||||
"timers-browserify": "^2.0.12",
|
|
||||||
"tty-browserify": "0.0.1",
|
|
||||||
"url": "^0.11.0",
|
|
||||||
"util": "^0.12.4",
|
"util": "^0.12.4",
|
||||||
"vm-browserify": "^1.1.2",
|
|
||||||
"webdav": "^4.7.0",
|
"webdav": "^4.7.0",
|
||||||
"webdav-fs": "^4.0.0"
|
"webdav-fs": "^4.0.0",
|
||||||
|
"xregexp": "^5.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Only type defs here.
|
||||||
|
* To avoid circular dependency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "dropbox" | "onedrive";
|
||||||
|
|
||||||
|
export interface S3Config {
|
||||||
|
s3Endpoint: string;
|
||||||
|
s3Region: string;
|
||||||
|
s3AccessKeyID: string;
|
||||||
|
s3SecretAccessKey: string;
|
||||||
|
s3BucketName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropboxConfig {
|
||||||
|
accessToken: string;
|
||||||
|
clientID: string;
|
||||||
|
refreshToken: string;
|
||||||
|
accessTokenExpiresInSeconds: number;
|
||||||
|
accessTokenExpiresAtTime: number;
|
||||||
|
accountID: string;
|
||||||
|
username: string;
|
||||||
|
credentialsShouldBeDeletedAtTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebdavAuthType = "digest" | "basic";
|
||||||
|
|
||||||
|
export interface WebdavConfig {
|
||||||
|
address: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
authType: WebdavAuthType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnedriveConfig {
|
||||||
|
accessToken: string;
|
||||||
|
clientID: string;
|
||||||
|
authority: string;
|
||||||
|
refreshToken: string;
|
||||||
|
accessTokenExpiresInSeconds: number;
|
||||||
|
accessTokenExpiresAtTime: number;
|
||||||
|
deltaLink: string;
|
||||||
|
username: string;
|
||||||
|
credentialsShouldBeDeletedAtTime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemotelySavePluginSettings {
|
||||||
|
s3: S3Config;
|
||||||
|
webdav: WebdavConfig;
|
||||||
|
dropbox: DropboxConfig;
|
||||||
|
onedrive: OnedriveConfig;
|
||||||
|
password: string;
|
||||||
|
serviceType: SUPPORTED_SERVICES_TYPE;
|
||||||
|
currLogLevel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteItem {
|
||||||
|
key: string;
|
||||||
|
lastModified: number;
|
||||||
|
size: number;
|
||||||
|
remoteType: SUPPORTED_SERVICES_TYPE;
|
||||||
|
etag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COMMAND_URI = "remotely-save";
|
||||||
|
export const COMMAND_CALLBACK = "remotely-save-cb";
|
||||||
|
export const COMMAND_CALLBACK_ONEDRIVE = "remotely-save-cb-onedrive";
|
||||||
|
export const COMMAND_CALLBACK_DROPBOX = "remotely-save-cb-dropbox";
|
||||||
|
|
||||||
|
export interface UriParams {
|
||||||
|
func?: string;
|
||||||
|
vault?: string;
|
||||||
|
ver?: string;
|
||||||
|
data?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 80 days
|
||||||
|
export const OAUTH2_FORCE_EXPIRE_MILLISECONDS = 1000 * 60 * 60 * 24 * 80;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
||||||
|
|
||||||
|
import type { SyncPlanType } from "./sync";
|
||||||
|
import { readAllSyncPlanRecordTexts } from "./localdb";
|
||||||
|
import type { InternalDBs } from "./localdb";
|
||||||
|
import { mkdirpInVault } from "./misc";
|
||||||
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
|
const DEFAULT_DEBUG_FOLDER = "_debug_remotely_save/";
|
||||||
|
const DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX = "sync_plans_hist_exported_on_";
|
||||||
|
|
||||||
|
export const exportSyncPlansToFiles = async (db: InternalDBs, vault: Vault) => {
|
||||||
|
log.info("exporting");
|
||||||
|
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
||||||
|
const records = await readAllSyncPlanRecordTexts(db);
|
||||||
|
let md = "";
|
||||||
|
if (records.length === 0) {
|
||||||
|
md = "No sync plans history found";
|
||||||
|
} else {
|
||||||
|
md =
|
||||||
|
"Sync plans found:\n\n" +
|
||||||
|
records.map((x) => "```json\n" + x + "\n```\n").join("\n");
|
||||||
|
}
|
||||||
|
const ts = Date.now();
|
||||||
|
const filePath = `${DEFAULT_DEBUG_FOLDER}${DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX}${ts}.md`;
|
||||||
|
await vault.create(filePath, md, {
|
||||||
|
mtime: ts,
|
||||||
|
});
|
||||||
|
log.info("finish exporting");
|
||||||
|
};
|
||||||
+146
-70
@@ -1,91 +1,167 @@
|
|||||||
import * as crypto from "crypto";
|
import { base32, base64url } from "rfc4648";
|
||||||
import * as base32 from "hi-base32";
|
import { bufferToArrayBuffer, hexStringToTypedArray } from "./misc";
|
||||||
import { bufferToArrayBuffer, arrayBufferToBuffer } from "./misc";
|
|
||||||
|
|
||||||
const DEFAULT_ITER = 10000;
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
export const encryptBuffer = (
|
const DEFAULT_ITER = 20000;
|
||||||
buf: Buffer,
|
|
||||||
|
// base32.stringify(Buffer.from('Salted__'))
|
||||||
|
export const MAGIC_ENCRYPTED_PREFIX_BASE32 = "KNQWY5DFMRPV";
|
||||||
|
// base64.stringify(Buffer.from('Salted__'))
|
||||||
|
export const MAGIC_ENCRYPTED_PREFIX_BASE64URL = "U2FsdGVkX";
|
||||||
|
|
||||||
|
const getKeyIVFromPassword = async (
|
||||||
|
salt: Uint8Array,
|
||||||
password: string,
|
password: string,
|
||||||
rounds: number = DEFAULT_ITER
|
rounds: number = DEFAULT_ITER
|
||||||
) => {
|
) => {
|
||||||
const salt = crypto.randomBytes(8);
|
const k1 = await window.crypto.subtle.importKey(
|
||||||
const derivedKey = crypto.pbkdf2Sync(
|
"raw",
|
||||||
password,
|
new TextEncoder().encode(password),
|
||||||
salt,
|
{ name: "PBKDF2" },
|
||||||
rounds,
|
false,
|
||||||
32 + 16,
|
["deriveKey", "deriveBits"]
|
||||||
"sha256"
|
|
||||||
);
|
);
|
||||||
const key = derivedKey.slice(0, 32);
|
|
||||||
const iv = derivedKey.slice(32, 32 + 16);
|
const k2 = await window.crypto.subtle.deriveBits(
|
||||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
{
|
||||||
cipher.write(buf);
|
name: "PBKDF2",
|
||||||
cipher.end();
|
salt: salt,
|
||||||
const encrypted = cipher.read();
|
iterations: rounds,
|
||||||
const res = Buffer.concat([Buffer.from("Salted__"), salt, encrypted]);
|
hash: "SHA-256",
|
||||||
return res;
|
},
|
||||||
|
k1,
|
||||||
|
256 + 128
|
||||||
|
);
|
||||||
|
|
||||||
|
return k2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decryptBuffer = (
|
export const encryptArrayBuffer = async (
|
||||||
buf: Buffer,
|
arrBuf: ArrayBuffer,
|
||||||
password: string,
|
password: string,
|
||||||
rounds: number = DEFAULT_ITER
|
rounds: number = DEFAULT_ITER,
|
||||||
|
saltHex: string = ""
|
||||||
) => {
|
) => {
|
||||||
const prefix = buf.slice(0, 8);
|
let salt: Uint8Array;
|
||||||
const salt = buf.slice(8, 16);
|
if (saltHex !== "") {
|
||||||
const derivedKey = crypto.pbkdf2Sync(
|
salt = hexStringToTypedArray(saltHex);
|
||||||
password,
|
} else {
|
||||||
salt,
|
salt = window.crypto.getRandomValues(new Uint8Array(8));
|
||||||
rounds,
|
}
|
||||||
32 + 16,
|
|
||||||
"sha256"
|
const derivedKey = await getKeyIVFromPassword(salt, password, rounds);
|
||||||
);
|
|
||||||
const key = derivedKey.slice(0, 32);
|
const key = derivedKey.slice(0, 32);
|
||||||
const iv = derivedKey.slice(32, 32 + 16);
|
const iv = derivedKey.slice(32, 32 + 16);
|
||||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
||||||
decipher.write(buf.slice(16));
|
const keyCrypt = await window.crypto.subtle.importKey(
|
||||||
decipher.end();
|
"raw",
|
||||||
const decrypted = decipher.read();
|
key,
|
||||||
return decrypted as Buffer;
|
{ name: "AES-CBC" },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const enc = (await window.crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-CBC", iv },
|
||||||
|
keyCrypt,
|
||||||
|
arrBuf
|
||||||
|
)) as ArrayBuffer;
|
||||||
|
|
||||||
|
const prefix = new TextEncoder().encode("Salted__");
|
||||||
|
|
||||||
|
const res = new Uint8Array([...prefix, ...salt, ...new Uint8Array(enc)]);
|
||||||
|
|
||||||
|
return bufferToArrayBuffer(res);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const encryptArrayBuffer = (
|
export const decryptArrayBuffer = async (
|
||||||
arrBuf: ArrayBuffer,
|
arrBuf: ArrayBuffer,
|
||||||
password: string,
|
password: string,
|
||||||
rounds: number = DEFAULT_ITER
|
rounds: number = DEFAULT_ITER
|
||||||
) => {
|
) => {
|
||||||
return bufferToArrayBuffer(
|
const prefix = arrBuf.slice(0, 8);
|
||||||
encryptBuffer(arrayBufferToBuffer(arrBuf), password, rounds)
|
const salt = arrBuf.slice(8, 16);
|
||||||
);
|
const derivedKey = await getKeyIVFromPassword(
|
||||||
};
|
new Uint8Array(salt),
|
||||||
|
|
||||||
export const decryptArrayBuffer = (
|
|
||||||
arrBuf: ArrayBuffer,
|
|
||||||
password: string,
|
|
||||||
rounds: number = DEFAULT_ITER
|
|
||||||
) => {
|
|
||||||
return bufferToArrayBuffer(
|
|
||||||
decryptBuffer(arrayBufferToBuffer(arrBuf), password, rounds)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const encryptStringToBase32 = (
|
|
||||||
text: string,
|
|
||||||
password: string,
|
|
||||||
rounds: number = DEFAULT_ITER
|
|
||||||
) => {
|
|
||||||
return base32.encode(encryptBuffer(Buffer.from(text), password, rounds));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decryptBase32ToString = (
|
|
||||||
text: string,
|
|
||||||
password: string,
|
|
||||||
rounds: number = DEFAULT_ITER
|
|
||||||
) => {
|
|
||||||
return decryptBuffer(
|
|
||||||
Buffer.from(base32.decode.asBytes(text)),
|
|
||||||
password,
|
password,
|
||||||
rounds
|
rounds
|
||||||
).toString();
|
);
|
||||||
|
const key = derivedKey.slice(0, 32);
|
||||||
|
const iv = derivedKey.slice(32, 32 + 16);
|
||||||
|
|
||||||
|
const keyCrypt = await window.crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
key,
|
||||||
|
{ name: "AES-CBC" },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dec = (await window.crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-CBC", iv },
|
||||||
|
keyCrypt,
|
||||||
|
arrBuf.slice(16)
|
||||||
|
)) as ArrayBuffer;
|
||||||
|
|
||||||
|
return dec;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encryptStringToBase32 = async (
|
||||||
|
text: string,
|
||||||
|
password: string,
|
||||||
|
rounds: number = DEFAULT_ITER,
|
||||||
|
saltHex: string = ""
|
||||||
|
) => {
|
||||||
|
const enc = await encryptArrayBuffer(
|
||||||
|
bufferToArrayBuffer(new TextEncoder().encode(text)),
|
||||||
|
password,
|
||||||
|
rounds,
|
||||||
|
saltHex
|
||||||
|
);
|
||||||
|
return base32.stringify(new Uint8Array(enc), { pad: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptBase32ToString = async (
|
||||||
|
text: string,
|
||||||
|
password: string,
|
||||||
|
rounds: number = DEFAULT_ITER
|
||||||
|
) => {
|
||||||
|
return new TextDecoder().decode(
|
||||||
|
await decryptArrayBuffer(
|
||||||
|
bufferToArrayBuffer(base32.parse(text, { loose: true })),
|
||||||
|
password,
|
||||||
|
rounds
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encryptStringToBase64url = async (
|
||||||
|
text: string,
|
||||||
|
password: string,
|
||||||
|
rounds: number = DEFAULT_ITER,
|
||||||
|
saltHex: string = ""
|
||||||
|
) => {
|
||||||
|
const enc = await encryptArrayBuffer(
|
||||||
|
bufferToArrayBuffer(new TextEncoder().encode(text)),
|
||||||
|
password,
|
||||||
|
rounds,
|
||||||
|
saltHex
|
||||||
|
);
|
||||||
|
return base64url.stringify(new Uint8Array(enc), { pad: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptBase64urlToString = async (
|
||||||
|
text: string,
|
||||||
|
password: string,
|
||||||
|
rounds: number = DEFAULT_ITER
|
||||||
|
) => {
|
||||||
|
return new TextDecoder().decode(
|
||||||
|
await decryptArrayBuffer(
|
||||||
|
bufferToArrayBuffer(base64url.parse(text, { loose: true })),
|
||||||
|
password,
|
||||||
|
rounds
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import QRCode from "qrcode";
|
||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
|
||||||
|
import {
|
||||||
|
COMMAND_URI,
|
||||||
|
UriParams,
|
||||||
|
RemotelySavePluginSettings,
|
||||||
|
} from "./baseTypes";
|
||||||
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
|
export const exportQrCodeUri = async (
|
||||||
|
settings: RemotelySavePluginSettings,
|
||||||
|
currentVaultName: string,
|
||||||
|
pluginVersion: string
|
||||||
|
) => {
|
||||||
|
const settings2 = cloneDeep(settings);
|
||||||
|
delete settings2.dropbox;
|
||||||
|
delete settings2.onedrive;
|
||||||
|
const data = encodeURIComponent(JSON.stringify(settings2));
|
||||||
|
const vault = encodeURIComponent(currentVaultName);
|
||||||
|
const version = encodeURIComponent(pluginVersion);
|
||||||
|
const rawUri = `obsidian://${COMMAND_URI}?func=settings&version=${version}&vault=${vault}&data=${data}`;
|
||||||
|
// log.info(uri)
|
||||||
|
const imgUri = await QRCode.toDataURL(rawUri);
|
||||||
|
return {
|
||||||
|
rawUri,
|
||||||
|
imgUri,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProcessQrCodeResultType {
|
||||||
|
status: "error" | "ok";
|
||||||
|
message: string;
|
||||||
|
result?: RemotelySavePluginSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importQrCodeUri = (
|
||||||
|
inputParams: any,
|
||||||
|
currentVaultName: string
|
||||||
|
): ProcessQrCodeResultType => {
|
||||||
|
let params = inputParams as UriParams;
|
||||||
|
if (
|
||||||
|
params.func === undefined ||
|
||||||
|
params.func !== "settings" ||
|
||||||
|
params.vault === undefined ||
|
||||||
|
params.data === undefined
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
message: `the uri is not for exporting/importing settings: ${JSON.stringify(
|
||||||
|
inputParams
|
||||||
|
)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.vault !== currentVaultName) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
message: `the target vault is ${
|
||||||
|
params.vault
|
||||||
|
} but you are currently in ${currentVaultName}: ${JSON.stringify(
|
||||||
|
inputParams
|
||||||
|
)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings = {} as RemotelySavePluginSettings;
|
||||||
|
try {
|
||||||
|
settings = JSON.parse(params.data);
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
message: `errors while parsing settings: ${JSON.stringify(inputParams)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
message: "ok",
|
||||||
|
result: settings,
|
||||||
|
};
|
||||||
|
};
|
||||||
+175
-146
@@ -1,85 +1,98 @@
|
|||||||
import * as lf from "lovefield-ts/dist/es6/lf.js";
|
import localforage from "localforage";
|
||||||
import { TAbstractFile, TFile, TFolder } from "obsidian";
|
import { TAbstractFile, TFile, TFolder } from "obsidian";
|
||||||
|
|
||||||
import type { SUPPORTED_SERVICES_TYPE } from "./misc";
|
import type { SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
||||||
|
import type { SyncPlanType } from "./sync";
|
||||||
|
|
||||||
export type DatabaseConnection = lf.DatabaseConnection;
|
export type LocalForage = typeof localforage;
|
||||||
|
|
||||||
export const DEFAULT_DB_NAME = "saveremotedb";
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
|
export const DEFAULT_DB_VERSION_NUMBER: number = 20211114;
|
||||||
|
export const DEFAULT_DB_NAME = "remotelysavedb";
|
||||||
|
export const DEFAULT_TBL_VERSION = "schemaversion";
|
||||||
export const DEFAULT_TBL_DELETE_HISTORY = "filefolderoperationhistory";
|
export const DEFAULT_TBL_DELETE_HISTORY = "filefolderoperationhistory";
|
||||||
export const DEFAULT_TBL_SYNC_MAPPING = "syncmetadatahistory";
|
export const DEFAULT_TBL_SYNC_MAPPING = "syncmetadatahistory";
|
||||||
|
export const DEFAULT_SYNC_PLANS_HISTORY = "syncplanshistory";
|
||||||
|
|
||||||
export interface FileFolderHistoryRecord {
|
export interface FileFolderHistoryRecord {
|
||||||
key: string;
|
key: string;
|
||||||
ctime: number;
|
ctime: number;
|
||||||
mtime: number;
|
mtime: number;
|
||||||
size: number;
|
size: number;
|
||||||
action_when: number;
|
actionWhen: number;
|
||||||
action_type: "delete" | "rename";
|
actionType: "delete" | "rename";
|
||||||
key_type: "folder" | "file";
|
keyType: "folder" | "file";
|
||||||
rename_to: string;
|
renameTo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncMetaMappingRecord {
|
interface SyncMetaMappingRecord {
|
||||||
local_key: string;
|
localKey: string;
|
||||||
remote_key: string;
|
remoteKey: string;
|
||||||
local_size: number;
|
localSize: number;
|
||||||
remote_size: number;
|
remoteSize: number;
|
||||||
local_mtime: number;
|
localMtime: number;
|
||||||
remote_mtime: number;
|
remoteMtime: number;
|
||||||
remote_extra_key: string;
|
remoteExtraKey: string;
|
||||||
remote_type: SUPPORTED_SERVICES_TYPE;
|
remoteType: SUPPORTED_SERVICES_TYPE;
|
||||||
key_type: "folder" | "file";
|
keyType: "folder" | "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncPlanRecord {
|
||||||
|
ts: number;
|
||||||
|
remoteType: string;
|
||||||
|
syncPlan: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InternalDBs {
|
||||||
|
versionTbl: LocalForage;
|
||||||
|
deleteHistoryTbl: LocalForage;
|
||||||
|
syncMappingTbl: LocalForage;
|
||||||
|
syncPlansTbl: LocalForage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const prepareDBs = async () => {
|
export const prepareDBs = async () => {
|
||||||
const schemaBuilder = lf.schema.create(DEFAULT_DB_NAME, 1);
|
const db = {
|
||||||
schemaBuilder
|
versionTbl: localforage.createInstance({
|
||||||
.createTable(DEFAULT_TBL_DELETE_HISTORY)
|
name: DEFAULT_DB_NAME,
|
||||||
.addColumn("id", lf.Type.INTEGER)
|
storeName: DEFAULT_TBL_VERSION,
|
||||||
.addColumn("key", lf.Type.STRING)
|
}),
|
||||||
.addColumn("ctime", lf.Type.INTEGER)
|
deleteHistoryTbl: localforage.createInstance({
|
||||||
.addColumn("mtime", lf.Type.INTEGER)
|
name: DEFAULT_DB_NAME,
|
||||||
.addColumn("size", lf.Type.INTEGER)
|
storeName: DEFAULT_TBL_DELETE_HISTORY,
|
||||||
.addColumn("action_when", lf.Type.INTEGER)
|
}),
|
||||||
.addColumn("action_type", lf.Type.STRING)
|
syncMappingTbl: localforage.createInstance({
|
||||||
.addColumn("key_type", lf.Type.STRING)
|
name: DEFAULT_DB_NAME,
|
||||||
.addPrimaryKey(["id"], true)
|
storeName: DEFAULT_TBL_SYNC_MAPPING,
|
||||||
.addIndex("idxKey", ["key"]);
|
}),
|
||||||
|
syncPlansTbl: localforage.createInstance({
|
||||||
|
name: DEFAULT_DB_NAME,
|
||||||
|
storeName: DEFAULT_SYNC_PLANS_HISTORY,
|
||||||
|
}),
|
||||||
|
} as InternalDBs;
|
||||||
|
|
||||||
schemaBuilder
|
const originalVersion = (await db.versionTbl.getItem("version")) as number;
|
||||||
.createTable(DEFAULT_TBL_SYNC_MAPPING)
|
if (originalVersion === null) {
|
||||||
.addColumn("id", lf.Type.INTEGER)
|
await db.versionTbl.setItem("version", DEFAULT_DB_VERSION_NUMBER);
|
||||||
.addColumn("local_key", lf.Type.STRING)
|
} else if (originalVersion === DEFAULT_DB_VERSION_NUMBER) {
|
||||||
.addColumn("remote_key", lf.Type.STRING)
|
// do nothing
|
||||||
.addColumn("local_size", lf.Type.INTEGER)
|
} else {
|
||||||
.addColumn("remote_size", lf.Type.INTEGER)
|
await migrateDBs(db, originalVersion, DEFAULT_DB_VERSION_NUMBER);
|
||||||
.addColumn("local_mtime", lf.Type.INTEGER)
|
}
|
||||||
.addColumn("remote_mtime", lf.Type.INTEGER)
|
|
||||||
.addColumn("key_type", lf.Type.STRING)
|
|
||||||
.addColumn("remote_extra_key", lf.Type.STRING)
|
|
||||||
.addColumn("remote_type", lf.Type.STRING)
|
|
||||||
.addNullable([
|
|
||||||
"remote_extra_key",
|
|
||||||
"remote_mtime",
|
|
||||||
"remote_size",
|
|
||||||
"local_mtime",
|
|
||||||
])
|
|
||||||
.addPrimaryKey(["id"], true)
|
|
||||||
.addIndex("idxkey", ["local_key", "remote_key"]);
|
|
||||||
|
|
||||||
const db = await schemaBuilder.connect({
|
log.info("db connected");
|
||||||
storeType: lf.DataStoreType.INDEXED_DB,
|
|
||||||
});
|
|
||||||
console.log("db connected");
|
|
||||||
return db;
|
return db;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const destroyDBs = async (db: lf.DatabaseConnection) => {
|
export const destroyDBs = async () => {
|
||||||
db.close();
|
// await localforage.dropInstance({
|
||||||
|
// name: DEFAULT_DB_NAME,
|
||||||
|
// });
|
||||||
|
// 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");
|
||||||
@@ -90,38 +103,35 @@ export const destroyDBs = async (db: lf.DatabaseConnection) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loadDeleteRenameHistoryTable = async (
|
const migrateDBs = async (db: InternalDBs, oldVer: number, newVer: number) => {
|
||||||
db: lf.DatabaseConnection
|
if (oldVer === newVer) {
|
||||||
) => {
|
return;
|
||||||
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
}
|
||||||
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
// not implemented
|
||||||
|
throw Error(`not supported internal db changes from ${oldVer} to ${newVer}`);
|
||||||
|
};
|
||||||
|
|
||||||
const records = await db
|
export const loadDeleteRenameHistoryTable = async (db: InternalDBs) => {
|
||||||
.select()
|
const records = [] as FileFolderHistoryRecord[];
|
||||||
.from(schema)
|
await db.deleteHistoryTbl.iterate((value, key, iterationNumber) => {
|
||||||
.orderBy(schema.col("action_when"), lf.Order.ASC)
|
records.push(value as FileFolderHistoryRecord);
|
||||||
.exec();
|
});
|
||||||
|
records.sort((a, b) => a.actionWhen - b.actionWhen); // ascending
|
||||||
return records as FileFolderHistoryRecord[];
|
return records;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearDeleteRenameHistoryOfKey = async (
|
export const clearDeleteRenameHistoryOfKey = async (
|
||||||
db: lf.DatabaseConnection,
|
db: InternalDBs,
|
||||||
key: string
|
key: string
|
||||||
) => {
|
) => {
|
||||||
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
await db.deleteHistoryTbl.removeItem(key);
|
||||||
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
|
||||||
|
|
||||||
await db.delete().from(tbl).where(tbl.col("key").eq(key)).exec();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const insertDeleteRecord = async (
|
export const insertDeleteRecord = async (
|
||||||
db: lf.DatabaseConnection,
|
db: InternalDBs,
|
||||||
fileOrFolder: TAbstractFile
|
fileOrFolder: TAbstractFile
|
||||||
) => {
|
) => {
|
||||||
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
// log.info(fileOrFolder);
|
||||||
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
|
||||||
// console.log(fileOrFolder);
|
|
||||||
let k: FileFolderHistoryRecord;
|
let k: FileFolderHistoryRecord;
|
||||||
if (fileOrFolder instanceof TFile) {
|
if (fileOrFolder instanceof TFile) {
|
||||||
k = {
|
k = {
|
||||||
@@ -129,10 +139,10 @@ export const insertDeleteRecord = async (
|
|||||||
ctime: fileOrFolder.stat.ctime,
|
ctime: fileOrFolder.stat.ctime,
|
||||||
mtime: fileOrFolder.stat.mtime,
|
mtime: fileOrFolder.stat.mtime,
|
||||||
size: fileOrFolder.stat.size,
|
size: fileOrFolder.stat.size,
|
||||||
action_when: Date.now(),
|
actionWhen: Date.now(),
|
||||||
action_type: "delete",
|
actionType: "delete",
|
||||||
key_type: "file",
|
keyType: "file",
|
||||||
rename_to: "",
|
renameTo: "",
|
||||||
};
|
};
|
||||||
} else if (fileOrFolder instanceof TFolder) {
|
} else if (fileOrFolder instanceof TFolder) {
|
||||||
// key should endswith "/"
|
// key should endswith "/"
|
||||||
@@ -144,24 +154,21 @@ export const insertDeleteRecord = async (
|
|||||||
ctime: 0,
|
ctime: 0,
|
||||||
mtime: 0,
|
mtime: 0,
|
||||||
size: 0,
|
size: 0,
|
||||||
action_when: Date.now(),
|
actionWhen: Date.now(),
|
||||||
action_type: "delete",
|
actionType: "delete",
|
||||||
key_type: "folder",
|
keyType: "folder",
|
||||||
rename_to: "",
|
renameTo: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const row = tbl.createRow(k);
|
await db.deleteHistoryTbl.setItem(k.key, k);
|
||||||
await db.insertOrReplace().into(tbl).values([row]).exec();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const insertRenameRecord = async (
|
export const insertRenameRecord = async (
|
||||||
db: lf.DatabaseConnection,
|
db: InternalDBs,
|
||||||
fileOrFolder: TAbstractFile,
|
fileOrFolder: TAbstractFile,
|
||||||
oldPath: string
|
oldPath: string
|
||||||
) => {
|
) => {
|
||||||
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
// log.info(fileOrFolder);
|
||||||
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
|
||||||
// console.log(fileOrFolder);
|
|
||||||
let k: FileFolderHistoryRecord;
|
let k: FileFolderHistoryRecord;
|
||||||
if (fileOrFolder instanceof TFile) {
|
if (fileOrFolder instanceof TFile) {
|
||||||
k = {
|
k = {
|
||||||
@@ -169,10 +176,10 @@ export const insertRenameRecord = async (
|
|||||||
ctime: fileOrFolder.stat.ctime,
|
ctime: fileOrFolder.stat.ctime,
|
||||||
mtime: fileOrFolder.stat.mtime,
|
mtime: fileOrFolder.stat.mtime,
|
||||||
size: fileOrFolder.stat.size,
|
size: fileOrFolder.stat.size,
|
||||||
action_when: Date.now(),
|
actionWhen: Date.now(),
|
||||||
action_type: "rename",
|
actionType: "rename",
|
||||||
key_type: "file",
|
keyType: "file",
|
||||||
rename_to: fileOrFolder.path,
|
renameTo: fileOrFolder.path,
|
||||||
};
|
};
|
||||||
} else if (fileOrFolder instanceof TFolder) {
|
} else if (fileOrFolder instanceof TFolder) {
|
||||||
const key = oldPath.endsWith("/") ? oldPath : `${oldPath}/`;
|
const key = oldPath.endsWith("/") ? oldPath : `${oldPath}/`;
|
||||||
@@ -184,25 +191,18 @@ export const insertRenameRecord = async (
|
|||||||
ctime: 0,
|
ctime: 0,
|
||||||
mtime: 0,
|
mtime: 0,
|
||||||
size: 0,
|
size: 0,
|
||||||
action_when: Date.now(),
|
actionWhen: Date.now(),
|
||||||
action_type: "rename",
|
actionType: "rename",
|
||||||
key_type: "folder",
|
keyType: "folder",
|
||||||
rename_to: renameTo,
|
renameTo: renameTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const row = tbl.createRow(k);
|
await db.deleteHistoryTbl.setItem(k.key, k);
|
||||||
await db.insertOrReplace().into(tbl).values([row]).exec();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllDeleteRenameRecords = async (db: lf.DatabaseConnection) => {
|
export const upsertSyncMetaMappingData = async (
|
||||||
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
serviceType: SUPPORTED_SERVICES_TYPE,
|
||||||
const res1 = await db.select().from(schema).exec();
|
db: InternalDBs,
|
||||||
const res2 = res1 as FileFolderHistoryRecord[];
|
|
||||||
return res2;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const upsertSyncMetaMappingDataS3 = async (
|
|
||||||
db: lf.DatabaseConnection,
|
|
||||||
localKey: string,
|
localKey: string,
|
||||||
localMTime: number,
|
localMTime: number,
|
||||||
localSize: number,
|
localSize: number,
|
||||||
@@ -211,50 +211,79 @@ export const upsertSyncMetaMappingDataS3 = async (
|
|||||||
remoteSize: number,
|
remoteSize: number,
|
||||||
remoteExtraKey: string /* ETag from s3 */
|
remoteExtraKey: string /* ETag from s3 */
|
||||||
) => {
|
) => {
|
||||||
const schema = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
|
|
||||||
const aggregratedInfo: SyncMetaMappingRecord = {
|
const aggregratedInfo: SyncMetaMappingRecord = {
|
||||||
local_key: localKey,
|
localKey: localKey,
|
||||||
local_mtime: localMTime,
|
localMtime: localMTime,
|
||||||
local_size: localSize,
|
localSize: localSize,
|
||||||
remote_key: remoteKey,
|
remoteKey: remoteKey,
|
||||||
remote_mtime: remoteMTime,
|
remoteMtime: remoteMTime,
|
||||||
remote_size: remoteSize,
|
remoteSize: remoteSize,
|
||||||
remote_extra_key: remoteExtraKey,
|
remoteExtraKey: remoteExtraKey,
|
||||||
remote_type: "s3",
|
remoteType: serviceType,
|
||||||
key_type: localKey.endsWith("/") ? "folder" : "file",
|
keyType: localKey.endsWith("/") ? "folder" : "file",
|
||||||
};
|
};
|
||||||
const row = schema.createRow(aggregratedInfo);
|
await db.syncMappingTbl.setItem(remoteKey, aggregratedInfo);
|
||||||
await db.insertOrReplace().into(schema).values([row]).exec();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSyncMetaMappingByRemoteKeyS3 = async (
|
export const getSyncMetaMappingByRemoteKey = async (
|
||||||
db: lf.DatabaseConnection,
|
serviceType: SUPPORTED_SERVICES_TYPE,
|
||||||
|
db: InternalDBs,
|
||||||
remoteKey: string,
|
remoteKey: string,
|
||||||
remoteMTime: number,
|
remoteMTime: number,
|
||||||
remoteExtraKey: string
|
remoteExtraKey: string
|
||||||
) => {
|
) => {
|
||||||
const schema = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
|
const potentialItem = (await db.syncMappingTbl.getItem(
|
||||||
const tbl = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
|
remoteKey
|
||||||
const res = (await db
|
)) as SyncMetaMappingRecord;
|
||||||
.select()
|
|
||||||
.from(tbl)
|
|
||||||
.where(
|
|
||||||
lf.op.and(
|
|
||||||
tbl.col("remote_key").eq(remoteKey),
|
|
||||||
tbl.col("remote_mtime").eq(remoteMTime),
|
|
||||||
tbl.col("remote_extra_key").eq(remoteExtraKey),
|
|
||||||
tbl.col("remote_type").eq("s3")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.exec()) as SyncMetaMappingRecord[];
|
|
||||||
|
|
||||||
if (res.length === 1) {
|
if (potentialItem === null) {
|
||||||
return res[0];
|
// no result was found
|
||||||
}
|
|
||||||
|
|
||||||
if (res.length === 0) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw Error("something bad in sync meta mapping!");
|
if (
|
||||||
|
potentialItem.remoteKey === remoteKey &&
|
||||||
|
potentialItem.remoteMtime === remoteMTime &&
|
||||||
|
potentialItem.remoteExtraKey === remoteExtraKey &&
|
||||||
|
potentialItem.remoteType === serviceType
|
||||||
|
) {
|
||||||
|
// the result was found
|
||||||
|
return potentialItem;
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllSyncMetaMapping = async (db: InternalDBs) => {
|
||||||
|
await db.syncMappingTbl.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertSyncPlanRecord = async (
|
||||||
|
db: InternalDBs,
|
||||||
|
syncPlan: SyncPlanType
|
||||||
|
) => {
|
||||||
|
const record = {
|
||||||
|
ts: syncPlan.ts,
|
||||||
|
remoteType: syncPlan.remoteType,
|
||||||
|
syncPlan: JSON.stringify(syncPlan /* directly stringify */, null, 2),
|
||||||
|
} as SyncPlanRecord;
|
||||||
|
await db.syncPlansTbl.setItem(`${syncPlan.ts}`, record);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllSyncPlanRecords = async (db: InternalDBs) => {
|
||||||
|
await db.syncPlansTbl.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readAllSyncPlanRecordTexts = async (db: InternalDBs) => {
|
||||||
|
const records = [] as SyncPlanRecord[];
|
||||||
|
await db.syncPlansTbl.iterate((value, key, iterationNumber) => {
|
||||||
|
records.push(value as SyncPlanRecord);
|
||||||
|
});
|
||||||
|
records.sort((a, b) => -(a.ts - b.ts)); // descending
|
||||||
|
|
||||||
|
if (records === undefined) {
|
||||||
|
return [] as string[];
|
||||||
|
} else {
|
||||||
|
return records.map((x) => x.syncPlan);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+392
-161
@@ -1,51 +1,88 @@
|
|||||||
|
import { Modal, Notice, Plugin, Setting } from "obsidian";
|
||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
import type { RemotelySavePluginSettings } from "./baseTypes";
|
||||||
import {
|
import {
|
||||||
App,
|
COMMAND_CALLBACK,
|
||||||
Modal,
|
COMMAND_CALLBACK_ONEDRIVE,
|
||||||
Notice,
|
COMMAND_CALLBACK_DROPBOX,
|
||||||
Plugin,
|
COMMAND_URI,
|
||||||
PluginSettingTab,
|
} from "./baseTypes";
|
||||||
Setting,
|
import { importQrCodeUri } from "./importExport";
|
||||||
request,
|
import type { InternalDBs } from "./localdb";
|
||||||
Platform,
|
|
||||||
TFile,
|
|
||||||
TFolder,
|
|
||||||
} from "obsidian";
|
|
||||||
import * as CodeMirror from "codemirror";
|
|
||||||
import type { DatabaseConnection } from "./localdb";
|
|
||||||
import {
|
import {
|
||||||
prepareDBs,
|
|
||||||
destroyDBs,
|
|
||||||
loadDeleteRenameHistoryTable,
|
|
||||||
insertDeleteRecord,
|
insertDeleteRecord,
|
||||||
insertRenameRecord,
|
insertRenameRecord,
|
||||||
getAllDeleteRenameRecords,
|
insertSyncPlanRecord,
|
||||||
|
loadDeleteRenameHistoryTable,
|
||||||
|
prepareDBs,
|
||||||
} from "./localdb";
|
} from "./localdb";
|
||||||
|
import { RemoteClient } from "./remote";
|
||||||
|
import {
|
||||||
|
DEFAULT_DROPBOX_CONFIG,
|
||||||
|
getAuthUrlAndVerifier as getAuthUrlAndVerifierDropbox,
|
||||||
|
sendAuthReq as sendAuthReqDropbox,
|
||||||
|
setConfigBySuccessfullAuthInplace as setConfigBySuccessfullAuthInplaceDropbox,
|
||||||
|
} from "./remoteForDropbox";
|
||||||
|
import {
|
||||||
|
AccessCodeResponseSuccessfulType,
|
||||||
|
DEFAULT_ONEDRIVE_CONFIG,
|
||||||
|
sendAuthReq as sendAuthReqOnedrive,
|
||||||
|
setConfigBySuccessfullAuthInplace as setConfigBySuccessfullAuthInplaceOnedrive,
|
||||||
|
} from "./remoteForOnedrive";
|
||||||
|
import { DEFAULT_S3_CONFIG } from "./remoteForS3";
|
||||||
|
import { DEFAULT_WEBDAV_CONFIG } from "./remoteForWebdav";
|
||||||
|
import { RemotelySaveSettingTab } from "./settings";
|
||||||
import type { SyncStatusType } from "./sync";
|
import type { SyncStatusType } from "./sync";
|
||||||
import { ensembleMixedStates, getOperation, doActualSync } from "./sync";
|
import { doActualSync, getSyncPlan, isPasswordOk } from "./sync";
|
||||||
import { DEFAULT_S3_CONFIG, getS3Client, listFromRemote, S3Config } from "./s3";
|
|
||||||
|
|
||||||
interface SaveRemotePluginSettings {
|
import * as origLog from "loglevel";
|
||||||
s3?: S3Config;
|
const log = origLog.getLogger("rs-default");
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: SaveRemotePluginSettings = {
|
const DEFAULT_SETTINGS: RemotelySavePluginSettings = {
|
||||||
s3: DEFAULT_S3_CONFIG,
|
s3: DEFAULT_S3_CONFIG,
|
||||||
|
webdav: DEFAULT_WEBDAV_CONFIG,
|
||||||
|
dropbox: DEFAULT_DROPBOX_CONFIG,
|
||||||
|
onedrive: DEFAULT_ONEDRIVE_CONFIG,
|
||||||
password: "",
|
password: "",
|
||||||
|
serviceType: "s3",
|
||||||
|
currLogLevel: "info",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class SaveRemotePlugin extends Plugin {
|
interface OAuth2Info {
|
||||||
settings: SaveRemotePluginSettings;
|
verifier?: string;
|
||||||
cm: CodeMirror.Editor;
|
helperModal?: Modal;
|
||||||
db: DatabaseConnection;
|
authDiv?: HTMLElement;
|
||||||
|
revokeDiv?: HTMLElement;
|
||||||
|
revokeAuthSetting?: Setting;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class RemotelySavePlugin extends Plugin {
|
||||||
|
settings: RemotelySavePluginSettings;
|
||||||
|
// cm: CodeMirror.Editor;
|
||||||
|
db: InternalDBs;
|
||||||
syncStatus: SyncStatusType;
|
syncStatus: SyncStatusType;
|
||||||
|
oauth2Info: OAuth2Info;
|
||||||
|
currLogLevel: string;
|
||||||
|
|
||||||
async onload() {
|
async onload() {
|
||||||
console.log("loading plugin obsidian-save-remote");
|
log.info(`loading plugin ${this.manifest.id}`);
|
||||||
|
|
||||||
|
this.oauth2Info = {
|
||||||
|
verifier: "",
|
||||||
|
helperModal: undefined,
|
||||||
|
authDiv: undefined,
|
||||||
|
revokeDiv: undefined,
|
||||||
|
revokeAuthSetting: undefined,
|
||||||
|
}; // init
|
||||||
|
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
|
|
||||||
|
if (this.settings.currLogLevel !== undefined) {
|
||||||
|
log.setLevel(this.settings.currLogLevel as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.checkIfOauthExpires();
|
||||||
|
|
||||||
await this.prepareDB();
|
await this.prepareDB();
|
||||||
|
|
||||||
this.syncStatus = "idle";
|
this.syncStatus = "idle";
|
||||||
@@ -62,189 +99,383 @@ export default class SaveRemotePlugin extends Plugin {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// this.addRibbonIcon("dice", "Misc", async () => {
|
this.registerObsidianProtocolHandler(COMMAND_URI, async (inputParams) => {
|
||||||
// const a = this.app.vault.getAllLoadedFiles();
|
const parsed = importQrCodeUri(inputParams, this.app.vault.getName());
|
||||||
// console.log(a);
|
if (parsed.status === "error") {
|
||||||
// const h = await getAllRecords(this.db);
|
new Notice(parsed.message);
|
||||||
// console.log(h);
|
} else {
|
||||||
// });
|
const copied = cloneDeep(parsed.result);
|
||||||
|
// new Notice(JSON.stringify(copied))
|
||||||
|
this.settings = Object.assign({}, this.settings, copied);
|
||||||
|
this.saveSettings();
|
||||||
|
new Notice(
|
||||||
|
`New not-oauth2 settings for ${this.manifest.name} saved. Reopen the plugin Settings to the effect.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.addRibbonIcon("switch", "Save Remote", async () => {
|
this.registerObsidianProtocolHandler(
|
||||||
|
COMMAND_CALLBACK,
|
||||||
|
async (inputParams) => {
|
||||||
|
new Notice(
|
||||||
|
`Your uri call a callback that's not supported yet: ${JSON.stringify(
|
||||||
|
inputParams
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.registerObsidianProtocolHandler(
|
||||||
|
COMMAND_CALLBACK_DROPBOX,
|
||||||
|
async (inputParams) => {
|
||||||
|
if (inputParams.code !== undefined) {
|
||||||
|
if (this.oauth2Info.helperModal !== undefined) {
|
||||||
|
this.oauth2Info.helperModal.contentEl.empty();
|
||||||
|
this.oauth2Info.helperModal.contentEl.createEl("p", {
|
||||||
|
text: "Connecting to Dropbox...",
|
||||||
|
});
|
||||||
|
this.oauth2Info.helperModal.contentEl.createEl("p", {
|
||||||
|
text: "Please DO NOT close this modal.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let authRes = await sendAuthReqDropbox(
|
||||||
|
this.settings.dropbox.clientID,
|
||||||
|
this.oauth2Info.verifier,
|
||||||
|
inputParams.code
|
||||||
|
);
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
setConfigBySuccessfullAuthInplaceDropbox(
|
||||||
|
this.settings.dropbox,
|
||||||
|
authRes,
|
||||||
|
() => self.saveSettings()
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = new RemoteClient(
|
||||||
|
"dropbox",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
this.settings.dropbox,
|
||||||
|
undefined,
|
||||||
|
this.app.vault.getName(),
|
||||||
|
() => self.saveSettings()
|
||||||
|
);
|
||||||
|
|
||||||
|
const username = await client.getUser();
|
||||||
|
this.settings.dropbox.username = username;
|
||||||
|
await this.saveSettings();
|
||||||
|
|
||||||
|
new Notice(`Good! We've connected to Dropbox as user ${username}!`);
|
||||||
|
|
||||||
|
this.oauth2Info.verifier = ""; // reset it
|
||||||
|
this.oauth2Info.helperModal?.close(); // close it
|
||||||
|
this.oauth2Info.helperModal = undefined;
|
||||||
|
|
||||||
|
this.oauth2Info.authDiv?.toggleClass(
|
||||||
|
"dropbox-auth-button-hide",
|
||||||
|
this.settings.dropbox.username !== ""
|
||||||
|
);
|
||||||
|
this.oauth2Info.authDiv = undefined;
|
||||||
|
|
||||||
|
this.oauth2Info.revokeAuthSetting?.setDesc(
|
||||||
|
`You've connected as user ${this.settings.dropbox.username}. If you want to disconnect, click this button.`
|
||||||
|
);
|
||||||
|
this.oauth2Info.revokeAuthSetting = undefined;
|
||||||
|
this.oauth2Info.revokeDiv?.toggleClass(
|
||||||
|
"dropbox-revoke-auth-button-hide",
|
||||||
|
this.settings.dropbox.username === ""
|
||||||
|
);
|
||||||
|
this.oauth2Info.revokeDiv = undefined;
|
||||||
|
} else {
|
||||||
|
new Notice(
|
||||||
|
"Something went wrong from response from Dropbox. Maybe you rejected the auth?"
|
||||||
|
);
|
||||||
|
throw Error(
|
||||||
|
`do not know how to deal with the callback: ${JSON.stringify(
|
||||||
|
inputParams
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.registerObsidianProtocolHandler(
|
||||||
|
COMMAND_CALLBACK_ONEDRIVE,
|
||||||
|
async (inputParams) => {
|
||||||
|
if (inputParams.code !== undefined) {
|
||||||
|
if (this.oauth2Info.helperModal !== undefined) {
|
||||||
|
this.oauth2Info.helperModal.contentEl.empty();
|
||||||
|
this.oauth2Info.helperModal.contentEl.createEl("p", {
|
||||||
|
text: "Connecting to Onedrive...",
|
||||||
|
});
|
||||||
|
this.oauth2Info.helperModal.contentEl.createEl("p", {
|
||||||
|
text: "Please DO NOT close this modal.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let rsp = await sendAuthReqOnedrive(
|
||||||
|
this.settings.onedrive.clientID,
|
||||||
|
this.settings.onedrive.authority,
|
||||||
|
inputParams.code,
|
||||||
|
this.oauth2Info.verifier
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((rsp as any).error !== undefined) {
|
||||||
|
throw Error(`${JSON.stringify(rsp)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const self = this;
|
||||||
|
setConfigBySuccessfullAuthInplaceOnedrive(
|
||||||
|
this.settings.onedrive,
|
||||||
|
rsp as AccessCodeResponseSuccessfulType,
|
||||||
|
() => self.saveSettings()
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = new RemoteClient(
|
||||||
|
"onedrive",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
this.settings.onedrive,
|
||||||
|
this.app.vault.getName(),
|
||||||
|
() => self.saveSettings()
|
||||||
|
);
|
||||||
|
this.settings.onedrive.username = await client.getUser();
|
||||||
|
await this.saveSettings();
|
||||||
|
|
||||||
|
this.oauth2Info.verifier = ""; // reset it
|
||||||
|
this.oauth2Info.helperModal?.close(); // close it
|
||||||
|
this.oauth2Info.helperModal = undefined;
|
||||||
|
|
||||||
|
this.oauth2Info.authDiv?.toggleClass(
|
||||||
|
"onedrive-auth-button-hide",
|
||||||
|
this.settings.onedrive.username !== ""
|
||||||
|
);
|
||||||
|
this.oauth2Info.authDiv = undefined;
|
||||||
|
|
||||||
|
this.oauth2Info.revokeAuthSetting?.setDesc(
|
||||||
|
`You've connected as user ${this.settings.onedrive.username}. If you want to disconnect, click this button.`
|
||||||
|
);
|
||||||
|
this.oauth2Info.revokeAuthSetting = undefined;
|
||||||
|
this.oauth2Info.revokeDiv?.toggleClass(
|
||||||
|
"onedrive-revoke-auth-button-hide",
|
||||||
|
this.settings.onedrive.username === ""
|
||||||
|
);
|
||||||
|
this.oauth2Info.revokeDiv = undefined;
|
||||||
|
} else {
|
||||||
|
new Notice(
|
||||||
|
"Something went wrong from response from OneDrive. Maybe you rejected the auth?"
|
||||||
|
);
|
||||||
|
throw Error(
|
||||||
|
`do not know how to deal with the callback: ${JSON.stringify(
|
||||||
|
inputParams
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.addRibbonIcon("switch", "Remotely Save", async () => {
|
||||||
if (this.syncStatus !== "idle") {
|
if (this.syncStatus !== "idle") {
|
||||||
new Notice("Save Remote already running!");
|
new Notice(
|
||||||
|
`Remotely Save already running in stage ${this.syncStatus}!`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
new Notice("Save Remote Sync Preparing");
|
try {
|
||||||
|
//log.info(`huh ${this.settings.password}`)
|
||||||
|
new Notice(
|
||||||
|
`1/7 Remotely Save Sync Preparing (${this.settings.serviceType})`
|
||||||
|
);
|
||||||
this.syncStatus = "preparing";
|
this.syncStatus = "preparing";
|
||||||
const s3Client = getS3Client(this.settings.s3);
|
|
||||||
const remoteRsp = await listFromRemote(s3Client, this.settings.s3);
|
new Notice("2/7 Starting to fetch remote meta data.");
|
||||||
|
this.syncStatus = "getting_remote_meta";
|
||||||
|
const self = this;
|
||||||
|
const client = new RemoteClient(
|
||||||
|
this.settings.serviceType,
|
||||||
|
this.settings.s3,
|
||||||
|
this.settings.webdav,
|
||||||
|
this.settings.dropbox,
|
||||||
|
this.settings.onedrive,
|
||||||
|
this.app.vault.getName(),
|
||||||
|
() => self.saveSettings()
|
||||||
|
);
|
||||||
|
const remoteRsp = await client.listFromRemote();
|
||||||
|
// log.info(remoteRsp);
|
||||||
|
|
||||||
|
new Notice("3/7 Starting to fetch local meta data.");
|
||||||
|
this.syncStatus = "getting_local_meta";
|
||||||
const local = this.app.vault.getAllLoadedFiles();
|
const local = this.app.vault.getAllLoadedFiles();
|
||||||
const localHistory = await loadDeleteRenameHistoryTable(this.db);
|
const localHistory = await loadDeleteRenameHistoryTable(this.db);
|
||||||
// console.log(remoteRsp);
|
// log.info(local);
|
||||||
// console.log(local);
|
// log.info(localHistory);
|
||||||
// console.log(localHistory);
|
|
||||||
|
|
||||||
const mixedStates = await ensembleMixedStates(
|
new Notice("4/7 Checking password correct or not.");
|
||||||
|
this.syncStatus = "checking_password";
|
||||||
|
const passwordCheckResult = await isPasswordOk(
|
||||||
|
remoteRsp.Contents,
|
||||||
|
this.settings.password
|
||||||
|
);
|
||||||
|
if (!passwordCheckResult.ok) {
|
||||||
|
new Notice("something goes wrong while checking password");
|
||||||
|
throw Error(passwordCheckResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
new Notice("5/7 Starting to generate sync plan.");
|
||||||
|
this.syncStatus = "generating_plan";
|
||||||
|
const syncPlan = await getSyncPlan(
|
||||||
remoteRsp.Contents,
|
remoteRsp.Contents,
|
||||||
local,
|
local,
|
||||||
localHistory,
|
localHistory,
|
||||||
this.db,
|
this.db,
|
||||||
|
client.serviceType,
|
||||||
this.settings.password
|
this.settings.password
|
||||||
);
|
);
|
||||||
|
log.info(syncPlan.mixedStates); // for debugging
|
||||||
for (const [key, val] of Object.entries(mixedStates)) {
|
await insertSyncPlanRecord(this.db, syncPlan);
|
||||||
getOperation(val, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(mixedStates);
|
|
||||||
|
|
||||||
// The operations above are read only and kind of safe.
|
// The operations above are read only and kind of safe.
|
||||||
// The operations below begins to write or delete (!!!) something.
|
// The operations below begins to write or delete (!!!) something.
|
||||||
|
|
||||||
new Notice("Save Remote Sync data exchanging!");
|
new Notice("6/7 Remotely Save Sync data exchanging!");
|
||||||
|
|
||||||
|
this.syncStatus = "syncing";
|
||||||
await doActualSync(
|
await doActualSync(
|
||||||
s3Client,
|
client,
|
||||||
this.settings.s3,
|
|
||||||
this.db,
|
this.db,
|
||||||
this.app.vault,
|
this.app.vault,
|
||||||
mixedStates,
|
syncPlan,
|
||||||
this.settings.password
|
this.settings.password
|
||||||
);
|
);
|
||||||
|
|
||||||
new Notice("Save Remote finish!");
|
new Notice("7/7 Remotely Save finish!");
|
||||||
|
this.syncStatus = "finish";
|
||||||
this.syncStatus = "idle";
|
this.syncStatus = "idle";
|
||||||
|
} catch (error) {
|
||||||
|
const msg = `Remotely Save error while ${this.syncStatus}`;
|
||||||
|
log.info(msg);
|
||||||
|
log.info(error);
|
||||||
|
new Notice(msg);
|
||||||
|
new Notice(error.message);
|
||||||
|
this.syncStatus = "idle";
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addSettingTab(new SaveRemoteSettingTab(this.app, this));
|
this.addSettingTab(new RemotelySaveSettingTab(this.app, this));
|
||||||
|
|
||||||
this.registerCodeMirror((cm: CodeMirror.Editor) => {
|
|
||||||
this.cm = cm;
|
|
||||||
console.log("codemirror registered.");
|
|
||||||
});
|
|
||||||
|
|
||||||
// this.registerDomEvent(document, "click", (evt: MouseEvent) => {
|
// this.registerDomEvent(document, "click", (evt: MouseEvent) => {
|
||||||
// console.log("click", evt);
|
// log.info("click", evt);
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// this.registerInterval(
|
// this.registerInterval(
|
||||||
// window.setInterval(() => console.log("setInterval"), 5 * 60 * 1000)
|
// window.setInterval(() => log.info("setInterval"), 5 * 60 * 1000)
|
||||||
// );
|
// );
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {
|
onunload() {
|
||||||
console.log("unloading plugin obsidian-save-remote");
|
log.info(`unloading plugin ${this.manifest.id}`);
|
||||||
this.destroyDBs();
|
this.destroyDBs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
this.settings = Object.assign(
|
||||||
|
{},
|
||||||
|
cloneDeep(DEFAULT_SETTINGS),
|
||||||
|
await this.loadData()
|
||||||
|
);
|
||||||
|
if (this.settings.dropbox.clientID === "") {
|
||||||
|
this.settings.dropbox.clientID = DEFAULT_SETTINGS.dropbox.clientID;
|
||||||
|
}
|
||||||
|
if (this.settings.onedrive.clientID === "") {
|
||||||
|
this.settings.onedrive.clientID = DEFAULT_SETTINGS.onedrive.clientID;
|
||||||
|
}
|
||||||
|
if (this.settings.onedrive.authority === "") {
|
||||||
|
this.settings.onedrive.authority = DEFAULT_SETTINGS.onedrive.authority;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
await this.saveData(this.settings);
|
await this.saveData(this.settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkIfOauthExpires() {
|
||||||
|
let needSave: boolean = false;
|
||||||
|
const current = Date.now();
|
||||||
|
|
||||||
|
// fullfill old version settings
|
||||||
|
if (
|
||||||
|
this.settings.dropbox.refreshToken !== "" &&
|
||||||
|
this.settings.dropbox.credentialsShouldBeDeletedAtTime === undefined
|
||||||
|
) {
|
||||||
|
// It has a refreshToken, but not expire time.
|
||||||
|
// Likely to be a setting from old version.
|
||||||
|
// we set it to a month.
|
||||||
|
this.settings.dropbox.credentialsShouldBeDeletedAtTime =
|
||||||
|
current + 1000 * 60 * 60 * 24 * 30;
|
||||||
|
needSave = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.settings.onedrive.refreshToken !== "" &&
|
||||||
|
this.settings.onedrive.credentialsShouldBeDeletedAtTime === undefined
|
||||||
|
) {
|
||||||
|
this.settings.onedrive.credentialsShouldBeDeletedAtTime =
|
||||||
|
current + 1000 * 60 * 60 * 24 * 30;
|
||||||
|
needSave = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// check expired or not
|
||||||
|
let dropboxExpired = false;
|
||||||
|
if (
|
||||||
|
this.settings.dropbox.refreshToken !== "" &&
|
||||||
|
current >= this.settings.dropbox.credentialsShouldBeDeletedAtTime
|
||||||
|
) {
|
||||||
|
dropboxExpired = true;
|
||||||
|
this.settings.dropbox = cloneDeep(DEFAULT_DROPBOX_CONFIG);
|
||||||
|
needSave = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let onedriveExpired = false;
|
||||||
|
if (
|
||||||
|
this.settings.onedrive.refreshToken !== "" &&
|
||||||
|
current >= this.settings.onedrive.credentialsShouldBeDeletedAtTime
|
||||||
|
) {
|
||||||
|
onedriveExpired = true;
|
||||||
|
this.settings.onedrive = cloneDeep(DEFAULT_ONEDRIVE_CONFIG);
|
||||||
|
needSave = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// save back
|
||||||
|
if (needSave) {
|
||||||
|
await this.saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// send notice
|
||||||
|
if (dropboxExpired && onedriveExpired) {
|
||||||
|
new Notice(
|
||||||
|
`${this.manifest.name}: You haven't manually auth Dropbox and OneDrive for a while, you need to re-auth them again.`,
|
||||||
|
6000
|
||||||
|
);
|
||||||
|
} else if (dropboxExpired) {
|
||||||
|
new Notice(
|
||||||
|
`${this.manifest.name}: You haven't manually auth Dropbox for a while, you need to re-auth it again.`,
|
||||||
|
6000
|
||||||
|
);
|
||||||
|
} else if (onedriveExpired) {
|
||||||
|
new Notice(
|
||||||
|
`${this.manifest.name}: You haven't manually auth OneDrive for a while, you need to re-auth it again.`,
|
||||||
|
6000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async prepareDB() {
|
async prepareDB() {
|
||||||
this.db = await prepareDBs();
|
this.db = await prepareDBs();
|
||||||
}
|
}
|
||||||
|
|
||||||
destroyDBs() {
|
destroyDBs() {
|
||||||
destroyDBs(this.db);
|
/* destroyDBs(this.db); */
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SaveRemoteSettingTab extends PluginSettingTab {
|
|
||||||
plugin: SaveRemotePlugin;
|
|
||||||
|
|
||||||
constructor(app: App, plugin: SaveRemotePlugin) {
|
|
||||||
super(app, plugin);
|
|
||||||
this.plugin = plugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
display(): void {
|
|
||||||
let { containerEl } = this;
|
|
||||||
|
|
||||||
containerEl.empty();
|
|
||||||
|
|
||||||
containerEl.createEl("h2", { text: "Settings for Save Remote" });
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName("s3Endpoint")
|
|
||||||
.setDesc("s3Endpoint")
|
|
||||||
.addText((text) =>
|
|
||||||
text
|
|
||||||
.setPlaceholder("")
|
|
||||||
.setValue(this.plugin.settings.s3.s3Endpoint)
|
|
||||||
.onChange(async (value) => {
|
|
||||||
this.plugin.settings.s3.s3Endpoint = value;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName("s3Region")
|
|
||||||
.setDesc("s3Region")
|
|
||||||
.addText((text) =>
|
|
||||||
text
|
|
||||||
.setPlaceholder("")
|
|
||||||
.setValue(`${this.plugin.settings.s3.s3Region}`)
|
|
||||||
.onChange(async (value) => {
|
|
||||||
this.plugin.settings.s3.s3Region = value;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName("s3AccessKeyID")
|
|
||||||
.setDesc("s3AccessKeyID")
|
|
||||||
.addText((text) =>
|
|
||||||
text
|
|
||||||
.setPlaceholder("")
|
|
||||||
.setValue(`${this.plugin.settings.s3.s3AccessKeyID}`)
|
|
||||||
.onChange(async (value) => {
|
|
||||||
this.plugin.settings.s3.s3AccessKeyID = value;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName("s3SecretAccessKey")
|
|
||||||
.setDesc("s3SecretAccessKey")
|
|
||||||
.addText((text) =>
|
|
||||||
text
|
|
||||||
.setPlaceholder("")
|
|
||||||
.setValue(`${this.plugin.settings.s3.s3SecretAccessKey}`)
|
|
||||||
.onChange(async (value) => {
|
|
||||||
this.plugin.settings.s3.s3SecretAccessKey = value;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName("password")
|
|
||||||
.setDesc("password")
|
|
||||||
.addText((text) =>
|
|
||||||
text
|
|
||||||
.setPlaceholder("")
|
|
||||||
.setValue(`${this.plugin.settings.password}`)
|
|
||||||
.onChange(async (value) => {
|
|
||||||
this.plugin.settings.password = value;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName("s3BucketName")
|
|
||||||
.setDesc("s3BucketName")
|
|
||||||
.addText((text) =>
|
|
||||||
text
|
|
||||||
.setPlaceholder("")
|
|
||||||
.setValue(`${this.plugin.settings.s3.s3BucketName}`)
|
|
||||||
.onChange(async (value) => {
|
|
||||||
this.plugin.settings.s3.s3BucketName = value;
|
|
||||||
await this.plugin.saveSettings();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-7
@@ -1,11 +1,34 @@
|
|||||||
import { Vault } from "obsidian";
|
import { Vault } from "obsidian";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "ftp";
|
import { base32, base64url } from "rfc4648";
|
||||||
|
import XRegExp from "xregexp";
|
||||||
|
|
||||||
export const ignoreHiddenFiles = (item: string) => {
|
import * as origLog from "loglevel";
|
||||||
const basename = path.basename(item);
|
const log = origLog.getLogger("rs-default");
|
||||||
return basename === "." || basename[0] !== ".";
|
|
||||||
|
/**
|
||||||
|
* If any part of the file starts with '.' or '_' then it's a hidden file.
|
||||||
|
* @param item
|
||||||
|
* @param loose
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const isHiddenPath = (item: string, loose: boolean = true) => {
|
||||||
|
const k = path.posix.normalize(item); // TODO: only unix path now
|
||||||
|
const k2 = k.split("/"); // TODO: only unix path now
|
||||||
|
// log.info(k2)
|
||||||
|
for (const singlePart of k2) {
|
||||||
|
if (singlePart === "." || singlePart === ".." || singlePart === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (singlePart[0] === ".") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (loose && singlePart[0] === "_") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,17 +48,23 @@ export const getFolderLevels = (x: string) => {
|
|||||||
const y1 = x.split("/");
|
const y1 = x.split("/");
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (let index = 0; index + 1 < y1.length; index++) {
|
for (let index = 0; index + 1 < y1.length; index++) {
|
||||||
res.push(y1.slice(0, index + 1).join("/"));
|
const k = y1.slice(0, index + 1).join("/");
|
||||||
|
if (k !== "" && k !== "/") {
|
||||||
|
res.push(k);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mkdirpInVault = async (thePath: string, vault: Vault) => {
|
export const mkdirpInVault = async (thePath: string, vault: Vault) => {
|
||||||
|
// log.info(thePath);
|
||||||
const foldersToBuild = getFolderLevels(thePath);
|
const foldersToBuild = getFolderLevels(thePath);
|
||||||
|
// 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);
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +75,7 @@ export const mkdirpInVault = async (thePath: string, vault: Vault) => {
|
|||||||
* @param b Buffer
|
* @param b Buffer
|
||||||
* @returns ArrayBuffer
|
* @returns ArrayBuffer
|
||||||
*/
|
*/
|
||||||
export const bufferToArrayBuffer = (b: Buffer) => {
|
export const bufferToArrayBuffer = (b: Buffer | Uint8Array) => {
|
||||||
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
|
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +92,110 @@ export const arrayBufferToBase64 = (b: ArrayBuffer) => {
|
|||||||
return arrayBufferToBuffer(b).toString("base64");
|
return arrayBufferToBuffer(b).toString("base64");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const arrayBufferToHex = (b: ArrayBuffer) => {
|
||||||
|
return arrayBufferToBuffer(b).toString("hex");
|
||||||
|
};
|
||||||
|
|
||||||
export const base64ToArrayBuffer = (b64text: string) => {
|
export const base64ToArrayBuffer = (b64text: string) => {
|
||||||
return bufferToArrayBuffer(Buffer.from(b64text, "base64"));
|
return bufferToArrayBuffer(Buffer.from(b64text, "base64"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://stackoverflow.com/questions/43131242
|
||||||
|
* @param hex
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const hexStringToTypedArray = (hex: string) => {
|
||||||
|
return new Uint8Array(
|
||||||
|
hex.match(/[\da-f]{2}/gi).map(function (h) {
|
||||||
|
return parseInt(h, 16);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const base64ToBase32 = (a: string) => {
|
||||||
|
return base32.stringify(Buffer.from(a, "base64"));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const base64ToBase64url = (a: string, pad: boolean = false) => {
|
||||||
|
let b = a.replace(/\+/g, "-").replace(/\//g, "_");
|
||||||
|
if (!pad) {
|
||||||
|
b = b.replace(/=/g, "");
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS Safari could decrypt string with invalid password!
|
||||||
|
* So we need an extra way to test the decrypted result.
|
||||||
|
* One simple way is testing the result are "valid", printable chars or not.
|
||||||
|
*
|
||||||
|
* https://stackoverflow.com/questions/6198986
|
||||||
|
* https://www.regular-expressions.info/unicode.html
|
||||||
|
* Manual test shows that emojis like '🍎' match '\\p{Cs}',
|
||||||
|
* so we need to write the regrex in a form that \p{C} minus \p{Cs}
|
||||||
|
* @param a
|
||||||
|
*/
|
||||||
|
export const isVaildText = (a: string) => {
|
||||||
|
// If the regex matches, the string is invalid.
|
||||||
|
return !XRegExp("\\p{Cc}|\\p{Cf}|\\p{Co}|\\p{Cn}|\\p{Zl}|\\p{Zp}", "A").test(
|
||||||
|
a
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If input is already a folder, returns it as is;
|
||||||
|
* And if input is a file, returns its direname.
|
||||||
|
* @param a
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const getPathFolder = (a: string) => {
|
||||||
|
if (a.endsWith("/")) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
const b = path.posix.dirname(a);
|
||||||
|
return b.endsWith("/") ? b : `${b}/`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://stackoverflow.com/questions/54511144
|
||||||
|
* @param a
|
||||||
|
* @param delimiter
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const setToString = (a: Set<string>, delimiter: string = ",") => {
|
||||||
|
return [...a].join(delimiter);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractSvgSub = (x: string, subEl: string = "rect") => {
|
||||||
|
const parser = new window.DOMParser();
|
||||||
|
const dom = parser.parseFromString(x, "image/svg+xml");
|
||||||
|
const svg = dom.querySelector("svg");
|
||||||
|
svg.setAttribute("viewbox", "0 0 10 10");
|
||||||
|
return svg.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://stackoverflow.com/questions/18230217
|
||||||
|
* @param min
|
||||||
|
* @param max
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const getRandomIntInclusive = (min: number, max: number) => {
|
||||||
|
const randomBuffer = new Uint32Array(1);
|
||||||
|
window.crypto.getRandomValues(randomBuffer);
|
||||||
|
let randomNumber = randomBuffer[0] / (0xffffffff + 1);
|
||||||
|
min = Math.ceil(min);
|
||||||
|
max = Math.floor(max);
|
||||||
|
return Math.floor(randomNumber * (max - min + 1)) + min;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Random buffer
|
||||||
|
* @param byteLength
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const getRandomArrayBuffer = (byteLength: number) => {
|
||||||
|
const k = window.crypto.getRandomValues(new Uint8Array(byteLength));
|
||||||
|
return bufferToArrayBuffer(k);
|
||||||
|
};
|
||||||
|
|||||||
+284
@@ -0,0 +1,284 @@
|
|||||||
|
import { Vault } from "obsidian";
|
||||||
|
import type {
|
||||||
|
DropboxConfig,
|
||||||
|
OnedriveConfig,
|
||||||
|
S3Config,
|
||||||
|
SUPPORTED_SERVICES_TYPE,
|
||||||
|
WebdavConfig,
|
||||||
|
} from "./baseTypes";
|
||||||
|
import * as dropbox from "./remoteForDropbox";
|
||||||
|
import * as onedrive from "./remoteForOnedrive";
|
||||||
|
import * as s3 from "./remoteForS3";
|
||||||
|
import * as webdav from "./remoteForWebdav";
|
||||||
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
|
export class RemoteClient {
|
||||||
|
readonly serviceType: SUPPORTED_SERVICES_TYPE;
|
||||||
|
readonly s3Client?: s3.S3Client;
|
||||||
|
readonly s3Config?: S3Config;
|
||||||
|
readonly webdavClient?: webdav.WrappedWebdavClient;
|
||||||
|
readonly webdavConfig?: WebdavConfig;
|
||||||
|
readonly dropboxClient?: dropbox.WrappedDropboxClient;
|
||||||
|
readonly dropboxConfig?: DropboxConfig;
|
||||||
|
readonly onedriveClient?: onedrive.WrappedOnedriveClient;
|
||||||
|
readonly onedriveConfig?: OnedriveConfig;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
serviceType: SUPPORTED_SERVICES_TYPE,
|
||||||
|
s3Config?: S3Config,
|
||||||
|
webdavConfig?: WebdavConfig,
|
||||||
|
dropboxConfig?: DropboxConfig,
|
||||||
|
onedriveConfig?: OnedriveConfig,
|
||||||
|
vaultName?: string,
|
||||||
|
saveUpdatedConfigFunc?: () => Promise<any>
|
||||||
|
) {
|
||||||
|
this.serviceType = serviceType;
|
||||||
|
// the client may modify the config inplace,
|
||||||
|
// so we use a ref not copy of config here
|
||||||
|
if (serviceType === "s3") {
|
||||||
|
this.s3Config = s3Config;
|
||||||
|
this.s3Client = s3.getS3Client(this.s3Config);
|
||||||
|
} else if (serviceType === "webdav") {
|
||||||
|
if (vaultName === undefined) {
|
||||||
|
throw Error("remember to provide vault name while init webdav client");
|
||||||
|
}
|
||||||
|
this.webdavConfig = webdavConfig;
|
||||||
|
this.webdavClient = webdav.getWebdavClient(this.webdavConfig, vaultName);
|
||||||
|
} else if (serviceType === "dropbox") {
|
||||||
|
if (vaultName === undefined || saveUpdatedConfigFunc === undefined) {
|
||||||
|
throw Error(
|
||||||
|
"remember to provide vault name and callback while init dropbox client"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.dropboxConfig = dropboxConfig;
|
||||||
|
this.dropboxClient = dropbox.getDropboxClient(
|
||||||
|
this.dropboxConfig,
|
||||||
|
vaultName,
|
||||||
|
saveUpdatedConfigFunc
|
||||||
|
);
|
||||||
|
} else if (serviceType === "onedrive") {
|
||||||
|
if (vaultName === undefined || saveUpdatedConfigFunc === undefined) {
|
||||||
|
throw Error(
|
||||||
|
"remember to provide vault name and callback while init onedrive client"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.onedriveConfig = onedriveConfig;
|
||||||
|
this.onedriveClient = onedrive.getOnedriveClient(
|
||||||
|
this.onedriveConfig,
|
||||||
|
vaultName,
|
||||||
|
saveUpdatedConfigFunc
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemoteMeta = async (fileOrFolderPath: string) => {
|
||||||
|
if (this.serviceType === "s3") {
|
||||||
|
return await s3.getRemoteMeta(
|
||||||
|
this.s3Client,
|
||||||
|
this.s3Config,
|
||||||
|
fileOrFolderPath
|
||||||
|
);
|
||||||
|
} else if (this.serviceType === "webdav") {
|
||||||
|
return await webdav.getRemoteMeta(this.webdavClient, fileOrFolderPath);
|
||||||
|
} else if (this.serviceType === "dropbox") {
|
||||||
|
return await dropbox.getRemoteMeta(this.dropboxClient, fileOrFolderPath);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.getRemoteMeta(
|
||||||
|
this.onedriveClient,
|
||||||
|
fileOrFolderPath
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
uploadToRemote = async (
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
vault: Vault,
|
||||||
|
isRecursively: boolean = false,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = "",
|
||||||
|
foldersCreatedBefore: Set<string> | undefined = undefined
|
||||||
|
) => {
|
||||||
|
if (this.serviceType === "s3") {
|
||||||
|
return await s3.uploadToRemote(
|
||||||
|
this.s3Client,
|
||||||
|
this.s3Config,
|
||||||
|
fileOrFolderPath,
|
||||||
|
vault,
|
||||||
|
isRecursively,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
|
} else if (this.serviceType === "webdav") {
|
||||||
|
return await webdav.uploadToRemote(
|
||||||
|
this.webdavClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
vault,
|
||||||
|
isRecursively,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
|
} else if (this.serviceType === "dropbox") {
|
||||||
|
return await dropbox.uploadToRemote(
|
||||||
|
this.dropboxClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
vault,
|
||||||
|
isRecursively,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey,
|
||||||
|
foldersCreatedBefore
|
||||||
|
);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.uploadToRemote(
|
||||||
|
this.onedriveClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
vault,
|
||||||
|
isRecursively,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey,
|
||||||
|
foldersCreatedBefore
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
listFromRemote = async (prefix?: string) => {
|
||||||
|
if (this.serviceType === "s3") {
|
||||||
|
return await s3.listFromRemote(this.s3Client, this.s3Config, prefix);
|
||||||
|
} else if (this.serviceType === "webdav") {
|
||||||
|
return await webdav.listFromRemote(this.webdavClient, prefix);
|
||||||
|
} else if (this.serviceType === "dropbox") {
|
||||||
|
return await dropbox.listFromRemote(this.dropboxClient, prefix);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.listFromRemote(this.onedriveClient, prefix);
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
downloadFromRemote = async (
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
vault: Vault,
|
||||||
|
mtime: number,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = ""
|
||||||
|
) => {
|
||||||
|
if (this.serviceType === "s3") {
|
||||||
|
return await s3.downloadFromRemote(
|
||||||
|
this.s3Client,
|
||||||
|
this.s3Config,
|
||||||
|
fileOrFolderPath,
|
||||||
|
vault,
|
||||||
|
mtime,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
|
} else if (this.serviceType === "webdav") {
|
||||||
|
return await webdav.downloadFromRemote(
|
||||||
|
this.webdavClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
vault,
|
||||||
|
mtime,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
|
} else if (this.serviceType === "dropbox") {
|
||||||
|
return await dropbox.downloadFromRemote(
|
||||||
|
this.dropboxClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
vault,
|
||||||
|
mtime,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.downloadFromRemote(
|
||||||
|
this.onedriveClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
vault,
|
||||||
|
mtime,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteFromRemote = async (
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = ""
|
||||||
|
) => {
|
||||||
|
if (this.serviceType === "s3") {
|
||||||
|
return await s3.deleteFromRemote(
|
||||||
|
this.s3Client,
|
||||||
|
this.s3Config,
|
||||||
|
fileOrFolderPath,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
|
} else if (this.serviceType === "webdav") {
|
||||||
|
return await webdav.deleteFromRemote(
|
||||||
|
this.webdavClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
|
} else if (this.serviceType === "dropbox") {
|
||||||
|
return await dropbox.deleteFromRemote(
|
||||||
|
this.dropboxClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.deleteFromRemote(
|
||||||
|
this.onedriveClient,
|
||||||
|
fileOrFolderPath,
|
||||||
|
password,
|
||||||
|
remoteEncryptedKey
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkConnectivity = async () => {
|
||||||
|
if (this.serviceType === "s3") {
|
||||||
|
return await s3.checkConnectivity(this.s3Client, this.s3Config);
|
||||||
|
} else if (this.serviceType === "webdav") {
|
||||||
|
return await webdav.checkConnectivity(this.webdavClient);
|
||||||
|
} else if (this.serviceType === "dropbox") {
|
||||||
|
return await dropbox.checkConnectivity(this.dropboxClient);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.checkConnectivity(this.onedriveClient);
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getUser = async () => {
|
||||||
|
if (this.serviceType === "dropbox") {
|
||||||
|
return await dropbox.getUserDisplayName(this.dropboxClient);
|
||||||
|
} else if (this.serviceType === "onedrive") {
|
||||||
|
return await onedrive.getUserDisplayName(this.onedriveClient);
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
revokeAuth = async () => {
|
||||||
|
if (this.serviceType === "dropbox") {
|
||||||
|
return await dropbox.revokeAuth(this.dropboxClient);
|
||||||
|
} else {
|
||||||
|
throw Error(`not supported service type ${this.serviceType}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,634 @@
|
|||||||
|
import { Dropbox, DropboxAuth, files } from "dropbox";
|
||||||
|
import { Vault } from "obsidian";
|
||||||
|
import * as path from "path";
|
||||||
|
import {
|
||||||
|
DropboxConfig,
|
||||||
|
RemoteItem,
|
||||||
|
COMMAND_CALLBACK_DROPBOX,
|
||||||
|
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||||
|
} from "./baseTypes";
|
||||||
|
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
||||||
|
import { bufferToArrayBuffer, getFolderLevels, mkdirpInVault } from "./misc";
|
||||||
|
|
||||||
|
export { Dropbox } from "dropbox";
|
||||||
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
|
export const DEFAULT_DROPBOX_CONFIG: DropboxConfig = {
|
||||||
|
accessToken: "",
|
||||||
|
clientID: process.env.DEFAULT_DROPBOX_APP_KEY,
|
||||||
|
refreshToken: "",
|
||||||
|
accessTokenExpiresInSeconds: 0,
|
||||||
|
accessTokenExpiresAtTime: 0,
|
||||||
|
accountID: "",
|
||||||
|
username: "",
|
||||||
|
credentialsShouldBeDeletedAtTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDropboxPath = (fileOrFolderPath: string, vaultName: string) => {
|
||||||
|
let key = fileOrFolderPath;
|
||||||
|
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||||
|
// special
|
||||||
|
key = `/${vaultName}`;
|
||||||
|
}
|
||||||
|
if (!fileOrFolderPath.startsWith("/")) {
|
||||||
|
// then this is original path in Obsidian
|
||||||
|
key = `/${vaultName}/${fileOrFolderPath}`;
|
||||||
|
}
|
||||||
|
if (key.endsWith("/")) {
|
||||||
|
key = key.slice(0, key.length - 1);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNormPath = (fileOrFolderPath: string, vaultName: string) => {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
fileOrFolderPath === `/${vaultName}` ||
|
||||||
|
fileOrFolderPath.startsWith(`/${vaultName}/`)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw Error(`"${fileOrFolderPath}" doesn't starts with "/${vaultName}/"`);
|
||||||
|
}
|
||||||
|
return fileOrFolderPath.slice(`/${vaultName}/`.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromDropboxItemToRemoteItem = (
|
||||||
|
x:
|
||||||
|
| files.FileMetadataReference
|
||||||
|
| files.FolderMetadataReference
|
||||||
|
| files.DeletedMetadataReference,
|
||||||
|
vaultName: string
|
||||||
|
): RemoteItem => {
|
||||||
|
let key = getNormPath(x.path_display, vaultName);
|
||||||
|
if (x[".tag"] === "folder" && !key.endsWith("/")) {
|
||||||
|
key = `${key}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x[".tag"] === "folder") {
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
lastModified: undefined,
|
||||||
|
size: 0,
|
||||||
|
remoteType: "dropbox",
|
||||||
|
etag: `${x.id}\t`,
|
||||||
|
} as RemoteItem;
|
||||||
|
} else if (x[".tag"] === "file") {
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
lastModified: Date.parse(x.server_modified).valueOf(),
|
||||||
|
size: x.size,
|
||||||
|
remoteType: "dropbox",
|
||||||
|
etag: `${x.id}\t${x.content_hash}`,
|
||||||
|
} as RemoteItem;
|
||||||
|
} else if (x[".tag"] === "deleted") {
|
||||||
|
throw Error("do not support deleted tag");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropbox api doesn't return mtime for folders.
|
||||||
|
* This is a try to assign mtime by using files in folder.
|
||||||
|
* @param allFilesFolders
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
const fixLastModifiedTimeInplace = (allFilesFolders: RemoteItem[]) => {
|
||||||
|
if (allFilesFolders.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort by longer to shorter
|
||||||
|
allFilesFolders.sort((a, b) => b.key.length - a.key.length);
|
||||||
|
|
||||||
|
// a "map" from dir to mtime
|
||||||
|
let potentialMTime = {} as Record<string, number>;
|
||||||
|
|
||||||
|
// first sort pass, from buttom to up
|
||||||
|
for (const item of allFilesFolders) {
|
||||||
|
if (item.key.endsWith("/")) {
|
||||||
|
// itself is a folder, and initially doesn't have mtime
|
||||||
|
if (item.lastModified === undefined && item.key in potentialMTime) {
|
||||||
|
// previously we gathered all sub info of this folder
|
||||||
|
item.lastModified = potentialMTime[item.key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parent = `${path.posix.dirname(item.key)}/`;
|
||||||
|
if (item.lastModified !== undefined) {
|
||||||
|
if (parent in potentialMTime) {
|
||||||
|
potentialMTime[parent] = Math.max(
|
||||||
|
potentialMTime[parent],
|
||||||
|
item.lastModified
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
potentialMTime[parent] = item.lastModified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// second pass, from up to buttom.
|
||||||
|
// fill mtime by parent folder or Date.Now() if still not available.
|
||||||
|
// this is only possible if no any sub-folder-files recursively.
|
||||||
|
// we do not sort the array again, just iterate over it by reverse
|
||||||
|
// using good old for loop.
|
||||||
|
for (let i = allFilesFolders.length - 1; i >= 0; --i) {
|
||||||
|
const item = allFilesFolders[i];
|
||||||
|
if (!item.key.endsWith("/")) {
|
||||||
|
continue; // skip files
|
||||||
|
}
|
||||||
|
if (item.lastModified !== undefined) {
|
||||||
|
continue; // don't need to deal with it
|
||||||
|
}
|
||||||
|
const parent = `${path.posix.dirname(item.key)}/`;
|
||||||
|
if (parent in potentialMTime) {
|
||||||
|
item.lastModified = potentialMTime[parent];
|
||||||
|
} else {
|
||||||
|
item.lastModified = Date.now().valueOf();
|
||||||
|
potentialMTime[item.key] = item.lastModified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allFilesFolders;
|
||||||
|
};
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Dropbox authorization using PKCE
|
||||||
|
// see https://dropbox.tech/developers/pkce--what-and-why-
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
export const getAuthUrlAndVerifier = async (appKey: string) => {
|
||||||
|
const auth = new DropboxAuth({
|
||||||
|
clientId: appKey,
|
||||||
|
});
|
||||||
|
const authUrl = (
|
||||||
|
await auth.getAuthenticationUrl(
|
||||||
|
`obsidian://${COMMAND_CALLBACK_DROPBOX}`,
|
||||||
|
undefined,
|
||||||
|
"code",
|
||||||
|
"offline",
|
||||||
|
undefined,
|
||||||
|
"none",
|
||||||
|
true
|
||||||
|
)
|
||||||
|
).toString();
|
||||||
|
const verifier = auth.getCodeVerifier();
|
||||||
|
return {
|
||||||
|
authUrl: authUrl,
|
||||||
|
verifier: verifier,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DropboxSuccessAuthRes {
|
||||||
|
access_token: string;
|
||||||
|
token_type: "bearer";
|
||||||
|
expires_in: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
scope?: string;
|
||||||
|
uid?: string;
|
||||||
|
account_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendAuthReq = async (
|
||||||
|
appKey: string,
|
||||||
|
verifier: string,
|
||||||
|
authCode: string
|
||||||
|
) => {
|
||||||
|
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
code: authCode,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code_verifier: verifier,
|
||||||
|
client_id: appKey,
|
||||||
|
redirect_uri: `obsidian://${COMMAND_CALLBACK_DROPBOX}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
||||||
|
return resp2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendRefreshTokenReq = async (
|
||||||
|
appKey: string,
|
||||||
|
refreshToken: string
|
||||||
|
) => {
|
||||||
|
log.info("start auto getting refreshed Dropbox access token.");
|
||||||
|
const resp1 = await fetch("https://api.dropboxapi.com/oauth2/token", {
|
||||||
|
method: "POST",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
client_id: appKey,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
||||||
|
log.info("finish auto getting refreshed Dropbox access token.");
|
||||||
|
return resp2;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setConfigBySuccessfullAuthInplace = async (
|
||||||
|
config: DropboxConfig,
|
||||||
|
authRes: DropboxSuccessAuthRes,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||||
|
) => {
|
||||||
|
log.info("start updating local info of Dropbox token");
|
||||||
|
|
||||||
|
config.accessToken = authRes.access_token;
|
||||||
|
config.accessTokenExpiresInSeconds = parseInt(authRes.expires_in);
|
||||||
|
config.accessTokenExpiresAtTime =
|
||||||
|
Date.now() + parseInt(authRes.expires_in) * 1000 - 10 * 1000;
|
||||||
|
|
||||||
|
// manually set it expired after 80 days;
|
||||||
|
config.credentialsShouldBeDeletedAtTime =
|
||||||
|
Date.now() + OAUTH2_FORCE_EXPIRE_MILLISECONDS;
|
||||||
|
|
||||||
|
if (authRes.refresh_token !== undefined) {
|
||||||
|
config.refreshToken = authRes.refresh_token;
|
||||||
|
}
|
||||||
|
if (authRes.refresh_token !== undefined) {
|
||||||
|
config.accountID = authRes.account_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveUpdatedConfigFunc !== undefined) {
|
||||||
|
await saveUpdatedConfigFunc();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("finish updating local info of Dropbox token");
|
||||||
|
};
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Other usual common methods
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
export class WrappedDropboxClient {
|
||||||
|
dropboxConfig: DropboxConfig;
|
||||||
|
vaultName: string;
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>;
|
||||||
|
dropbox: Dropbox;
|
||||||
|
vaultFolderExists: boolean;
|
||||||
|
constructor(
|
||||||
|
dropboxConfig: DropboxConfig,
|
||||||
|
vaultName: string,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>
|
||||||
|
) {
|
||||||
|
this.dropboxConfig = dropboxConfig;
|
||||||
|
this.vaultName = vaultName;
|
||||||
|
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||||
|
this.vaultFolderExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
init = async () => {
|
||||||
|
// check token
|
||||||
|
if (
|
||||||
|
this.dropboxConfig.accessToken === "" ||
|
||||||
|
this.dropboxConfig.refreshToken === ""
|
||||||
|
) {
|
||||||
|
throw Error("The user has not manually auth yet.");
|
||||||
|
}
|
||||||
|
const currentTs = Date.now();
|
||||||
|
if (this.dropboxConfig.accessTokenExpiresAtTime > currentTs) {
|
||||||
|
this.dropbox = new Dropbox({
|
||||||
|
accessToken: this.dropboxConfig.accessToken,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (this.dropboxConfig.refreshToken === "") {
|
||||||
|
throw Error(
|
||||||
|
"We need to automatically refresh token but none is stored."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const resp = await sendRefreshTokenReq(
|
||||||
|
this.dropboxConfig.clientID,
|
||||||
|
this.dropboxConfig.refreshToken
|
||||||
|
);
|
||||||
|
|
||||||
|
setConfigBySuccessfullAuthInplace(
|
||||||
|
this.dropboxConfig,
|
||||||
|
resp,
|
||||||
|
this.saveUpdatedConfigFunc
|
||||||
|
);
|
||||||
|
this.dropbox = new Dropbox({
|
||||||
|
accessToken: this.dropboxConfig.accessToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// check vault folder
|
||||||
|
// log.info(`checking remote has folder /${this.vaultName}`);
|
||||||
|
if (this.vaultFolderExists) {
|
||||||
|
// log.info(`already checked, /${this.vaultName} exist before`)
|
||||||
|
} else {
|
||||||
|
const res = await this.dropbox.filesListFolder({
|
||||||
|
path: "",
|
||||||
|
recursive: false,
|
||||||
|
});
|
||||||
|
for (const item of res.result.entries) {
|
||||||
|
if (item.path_display === `/${this.vaultName}`) {
|
||||||
|
this.vaultFolderExists = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.vaultFolderExists) {
|
||||||
|
log.info(`remote does not have folder /${this.vaultName}`);
|
||||||
|
await this.dropbox.filesCreateFolderV2({
|
||||||
|
path: `/${this.vaultName}`,
|
||||||
|
});
|
||||||
|
log.info(`remote folder /${this.vaultName} created`);
|
||||||
|
this.vaultFolderExists = true;
|
||||||
|
} else {
|
||||||
|
// log.info(`remote folder /${this.vaultName} exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dropbox;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param dropboxConfig
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const getDropboxClient = (
|
||||||
|
dropboxConfig: DropboxConfig,
|
||||||
|
vaultName: string,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>
|
||||||
|
) => {
|
||||||
|
return new WrappedDropboxClient(
|
||||||
|
dropboxConfig,
|
||||||
|
vaultName,
|
||||||
|
saveUpdatedConfigFunc
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRemoteMeta = async (
|
||||||
|
client: WrappedDropboxClient,
|
||||||
|
fileOrFolderPath: string
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
if (fileOrFolderPath === "" || fileOrFolderPath === "/") {
|
||||||
|
// filesGetMetadata doesn't support root folder
|
||||||
|
// we instead try to list files
|
||||||
|
// if no error occurs, we ensemble a fake result.
|
||||||
|
const rsp = await client.dropbox.filesListFolder({
|
||||||
|
path: `/${client.vaultName}`,
|
||||||
|
recursive: false, // don't need to recursive here
|
||||||
|
});
|
||||||
|
if (rsp.status !== 200) {
|
||||||
|
throw Error(JSON.stringify(rsp));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: fileOrFolderPath,
|
||||||
|
lastModified: undefined,
|
||||||
|
size: 0,
|
||||||
|
remoteType: "dropbox",
|
||||||
|
etag: undefined,
|
||||||
|
} as RemoteItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getDropboxPath(fileOrFolderPath, client.vaultName);
|
||||||
|
|
||||||
|
const rsp = await client.dropbox.filesGetMetadata({
|
||||||
|
path: key,
|
||||||
|
});
|
||||||
|
if (rsp.status !== 200) {
|
||||||
|
throw Error(JSON.stringify(rsp));
|
||||||
|
}
|
||||||
|
return fromDropboxItemToRemoteItem(rsp.result, client.vaultName);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadToRemote = async (
|
||||||
|
client: WrappedDropboxClient,
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
vault: Vault,
|
||||||
|
isRecursively: boolean = false,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = "",
|
||||||
|
foldersCreatedBefore: Set<string> | undefined = undefined
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
|
||||||
|
let uploadFile = fileOrFolderPath;
|
||||||
|
if (password !== "") {
|
||||||
|
uploadFile = remoteEncryptedKey;
|
||||||
|
}
|
||||||
|
uploadFile = getDropboxPath(uploadFile, client.vaultName);
|
||||||
|
|
||||||
|
const isFolder = fileOrFolderPath.endsWith("/");
|
||||||
|
|
||||||
|
if (isFolder && isRecursively) {
|
||||||
|
throw Error("upload function doesn't implement recursive function yet!");
|
||||||
|
} else if (isFolder && !isRecursively) {
|
||||||
|
// folder
|
||||||
|
if (password === "") {
|
||||||
|
// if not encrypted, mkdir a remote folder
|
||||||
|
if (foldersCreatedBefore?.has(uploadFile)) {
|
||||||
|
// created, pass
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await client.dropbox.filesCreateFolderV2({
|
||||||
|
path: uploadFile,
|
||||||
|
});
|
||||||
|
foldersCreatedBefore?.add(uploadFile);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 409) {
|
||||||
|
// pass
|
||||||
|
foldersCreatedBefore?.add(uploadFile);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await getRemoteMeta(client, uploadFile);
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
// if encrypted, upload a fake file with the encrypted file name
|
||||||
|
await client.dropbox.filesUpload({
|
||||||
|
path: uploadFile,
|
||||||
|
contents: "",
|
||||||
|
});
|
||||||
|
return await getRemoteMeta(client, uploadFile);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// file
|
||||||
|
// we ignore isRecursively parameter here
|
||||||
|
const localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
||||||
|
let remoteContent = localContent;
|
||||||
|
if (password !== "") {
|
||||||
|
remoteContent = await encryptArrayBuffer(localContent, password);
|
||||||
|
}
|
||||||
|
// in dropbox, we don't need to create folders before uploading! cool!
|
||||||
|
// TODO: filesUploadSession for larger files (>=150 MB)
|
||||||
|
await client.dropbox.filesUpload({
|
||||||
|
path: uploadFile,
|
||||||
|
contents: remoteContent,
|
||||||
|
mode: {
|
||||||
|
".tag": "overwrite",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// we want to mark that parent folders are created
|
||||||
|
if (foldersCreatedBefore !== undefined) {
|
||||||
|
const dirs = getFolderLevels(uploadFile).map((x) =>
|
||||||
|
getDropboxPath(x, client.vaultName)
|
||||||
|
);
|
||||||
|
for (const dir of dirs) {
|
||||||
|
foldersCreatedBefore?.add(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await getRemoteMeta(client, uploadFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listFromRemote = async (
|
||||||
|
client: WrappedDropboxClient,
|
||||||
|
prefix?: string
|
||||||
|
) => {
|
||||||
|
if (prefix !== undefined) {
|
||||||
|
throw Error("prefix not supported (yet)");
|
||||||
|
}
|
||||||
|
await client.init();
|
||||||
|
let res = await client.dropbox.filesListFolder({
|
||||||
|
path: `/${client.vaultName}`,
|
||||||
|
recursive: true,
|
||||||
|
include_deleted: false,
|
||||||
|
limit: 1000,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw Error(JSON.stringify(res));
|
||||||
|
}
|
||||||
|
// log.info(res);
|
||||||
|
|
||||||
|
const contents = res.result.entries;
|
||||||
|
const unifiedContents = contents
|
||||||
|
.filter((x) => x[".tag"] !== "deleted")
|
||||||
|
.filter((x) => x.path_display !== `/${client.vaultName}`)
|
||||||
|
.map((x) => fromDropboxItemToRemoteItem(x, client.vaultName));
|
||||||
|
|
||||||
|
while (res.result.has_more) {
|
||||||
|
res = await client.dropbox.filesListFolderContinue({
|
||||||
|
cursor: res.result.cursor,
|
||||||
|
});
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw Error(JSON.stringify(res));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents2 = res.result.entries;
|
||||||
|
const unifiedContents2 = contents2
|
||||||
|
.filter((x) => x[".tag"] !== "deleted")
|
||||||
|
.filter((x) => x.path_display !== `/${client.vaultName}`)
|
||||||
|
.map((x) => fromDropboxItemToRemoteItem(x, client.vaultName));
|
||||||
|
unifiedContents.push(...unifiedContents2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fixLastModifiedTimeInplace(unifiedContents);
|
||||||
|
|
||||||
|
return {
|
||||||
|
Contents: unifiedContents,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFromRemoteRaw = async (
|
||||||
|
client: WrappedDropboxClient,
|
||||||
|
fileOrFolderPath: string
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
const key = getDropboxPath(fileOrFolderPath, client.vaultName);
|
||||||
|
const rsp = await client.dropbox.filesDownload({
|
||||||
|
path: key,
|
||||||
|
});
|
||||||
|
if ((rsp.result as any).fileBlob !== undefined) {
|
||||||
|
// we get a Blob
|
||||||
|
const content = (rsp.result as any).fileBlob as Blob;
|
||||||
|
return await content.arrayBuffer();
|
||||||
|
} else if ((rsp.result as any).fileBinary !== undefined) {
|
||||||
|
// we get a Buffer
|
||||||
|
const content = (rsp.result as any).fileBinary as Buffer;
|
||||||
|
return bufferToArrayBuffer(content);
|
||||||
|
} else {
|
||||||
|
throw Error(`unknown rsp from dropbox download: ${rsp}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadFromRemote = async (
|
||||||
|
client: WrappedDropboxClient,
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
vault: Vault,
|
||||||
|
mtime: number,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = ""
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
|
||||||
|
const isFolder = fileOrFolderPath.endsWith("/");
|
||||||
|
|
||||||
|
await mkdirpInVault(fileOrFolderPath, vault);
|
||||||
|
|
||||||
|
// the file is always local file
|
||||||
|
// we need to encrypt it
|
||||||
|
|
||||||
|
if (isFolder) {
|
||||||
|
// mkdirp locally is enough
|
||||||
|
// do nothing here
|
||||||
|
} else {
|
||||||
|
let downloadFile = fileOrFolderPath;
|
||||||
|
if (password !== "") {
|
||||||
|
downloadFile = remoteEncryptedKey;
|
||||||
|
}
|
||||||
|
downloadFile = getDropboxPath(downloadFile, client.vaultName);
|
||||||
|
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
||||||
|
let localContent = remoteContent;
|
||||||
|
if (password !== "") {
|
||||||
|
localContent = await decryptArrayBuffer(remoteContent, password);
|
||||||
|
}
|
||||||
|
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||||
|
mtime: mtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFromRemote = async (
|
||||||
|
client: WrappedDropboxClient,
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = ""
|
||||||
|
) => {
|
||||||
|
if (fileOrFolderPath === "/") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let remoteFileName = fileOrFolderPath;
|
||||||
|
if (password !== "") {
|
||||||
|
remoteFileName = remoteEncryptedKey;
|
||||||
|
}
|
||||||
|
remoteFileName = getDropboxPath(remoteFileName, client.vaultName);
|
||||||
|
|
||||||
|
await client.init();
|
||||||
|
try {
|
||||||
|
await client.dropbox.filesDeleteV2({
|
||||||
|
path: remoteFileName,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("some error while deleting");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkConnectivity = async (client: WrappedDropboxClient) => {
|
||||||
|
try {
|
||||||
|
const results = await getRemoteMeta(client, "/");
|
||||||
|
if (results === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("dropbox connectivity error:");
|
||||||
|
console.error(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserDisplayName = async (client: WrappedDropboxClient) => {
|
||||||
|
await client.init();
|
||||||
|
const acct = await client.dropbox.usersGetCurrentAccount();
|
||||||
|
return acct.result.name.display_name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const revokeAuth = async (client: WrappedDropboxClient) => {
|
||||||
|
await client.init();
|
||||||
|
await client.dropbox.authTokenRevoke();
|
||||||
|
};
|
||||||
@@ -0,0 +1,740 @@
|
|||||||
|
import { CryptoProvider, PublicClientApplication } from "@azure/msal-node";
|
||||||
|
import {
|
||||||
|
AuthenticationProvider,
|
||||||
|
Client,
|
||||||
|
FileUpload,
|
||||||
|
LargeFileUploadSession,
|
||||||
|
LargeFileUploadTask,
|
||||||
|
LargeFileUploadTaskOptions,
|
||||||
|
Range,
|
||||||
|
UploadEventHandlers,
|
||||||
|
UploadResult,
|
||||||
|
} from "@microsoft/microsoft-graph-client";
|
||||||
|
import type { DriveItem, User } from "@microsoft/microsoft-graph-types";
|
||||||
|
import cloneDeep from "lodash/cloneDeep";
|
||||||
|
import { request, Vault } from "obsidian";
|
||||||
|
import * as path from "path";
|
||||||
|
import {
|
||||||
|
DropboxConfig,
|
||||||
|
OAUTH2_FORCE_EXPIRE_MILLISECONDS,
|
||||||
|
OnedriveConfig,
|
||||||
|
RemoteItem,
|
||||||
|
} from "./baseTypes";
|
||||||
|
import { COMMAND_CALLBACK_ONEDRIVE } from "./baseTypes";
|
||||||
|
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
||||||
|
import {
|
||||||
|
getRandomArrayBuffer,
|
||||||
|
getRandomIntInclusive,
|
||||||
|
mkdirpInVault,
|
||||||
|
} from "./misc";
|
||||||
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
|
const SCOPES = ["User.Read", "Files.ReadWrite.AppFolder", "offline_access"];
|
||||||
|
const REDIRECT_URI = `obsidian://${COMMAND_CALLBACK_ONEDRIVE}`;
|
||||||
|
|
||||||
|
export const DEFAULT_ONEDRIVE_CONFIG: OnedriveConfig = {
|
||||||
|
accessToken: "",
|
||||||
|
clientID: process.env.DEFAULT_ONEDRIVE_CLIENT_ID,
|
||||||
|
authority: process.env.DEFAULT_ONEDRIVE_AUTHORITY,
|
||||||
|
refreshToken: "",
|
||||||
|
accessTokenExpiresInSeconds: 0,
|
||||||
|
accessTokenExpiresAtTime: 0,
|
||||||
|
deltaLink: "",
|
||||||
|
username: "",
|
||||||
|
credentialsShouldBeDeletedAtTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Onedrive authorization using PKCE
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
export async function getAuthUrlAndVerifier(
|
||||||
|
clientID: string,
|
||||||
|
authority: string
|
||||||
|
) {
|
||||||
|
const cryptoProvider = new CryptoProvider();
|
||||||
|
const { verifier, challenge } = await cryptoProvider.generatePkceCodes();
|
||||||
|
|
||||||
|
const pkceCodes = {
|
||||||
|
challengeMethod: "S256", // Use SHA256 Algorithm
|
||||||
|
verifier: verifier,
|
||||||
|
challenge: challenge,
|
||||||
|
};
|
||||||
|
|
||||||
|
const authCodeUrlParams = {
|
||||||
|
redirectUri: REDIRECT_URI,
|
||||||
|
scopes: SCOPES,
|
||||||
|
codeChallenge: pkceCodes.challenge, // PKCE Code Challenge
|
||||||
|
codeChallengeMethod: pkceCodes.challengeMethod, // PKCE Code Challenge Method
|
||||||
|
};
|
||||||
|
|
||||||
|
const pca = new PublicClientApplication({
|
||||||
|
auth: {
|
||||||
|
clientId: clientID,
|
||||||
|
authority: authority,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const authCodeUrl = await pca.getAuthCodeUrl(authCodeUrlParams);
|
||||||
|
|
||||||
|
return {
|
||||||
|
authUrl: authCodeUrl,
|
||||||
|
verifier: verifier,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check doc from
|
||||||
|
* https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
|
||||||
|
* https://docs.microsoft.com/en-us/onedrive/developer/rest-api/getting-started/graph-oauth?view=odsp-graph-online#code-flow
|
||||||
|
*/
|
||||||
|
export interface AccessCodeResponseSuccessfulType {
|
||||||
|
token_type: "Bearer" | "bearer";
|
||||||
|
expires_in: number;
|
||||||
|
ext_expires_in?: number;
|
||||||
|
scope: string;
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
id_token?: string;
|
||||||
|
}
|
||||||
|
export interface AccessCodeResponseFailedType {
|
||||||
|
error: string;
|
||||||
|
error_description: string;
|
||||||
|
error_codes: number[];
|
||||||
|
timestamp: string;
|
||||||
|
trace_id: string;
|
||||||
|
correlation_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendAuthReq = async (
|
||||||
|
clientID: string,
|
||||||
|
authority: string,
|
||||||
|
authCode: string,
|
||||||
|
verifier: string
|
||||||
|
) => {
|
||||||
|
// // original code snippets for references
|
||||||
|
// const authResponse = await pca.acquireTokenByCode({
|
||||||
|
// redirectUri: REDIRECT_URI,
|
||||||
|
// scopes: SCOPES,
|
||||||
|
// code: authCode,
|
||||||
|
// codeVerifier: verifier, // PKCE Code Verifier
|
||||||
|
// });
|
||||||
|
// log.info('authResponse')
|
||||||
|
// log.info(authResponse)
|
||||||
|
// return authResponse;
|
||||||
|
|
||||||
|
// Because of the CORS problem,
|
||||||
|
// we need to construct raw request using Obsidian request,
|
||||||
|
// instead of using msal
|
||||||
|
// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
|
||||||
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/getting-started/graph-oauth?view=odsp-graph-online#code-flow
|
||||||
|
const rsp1 = await request({
|
||||||
|
url: `${authority}/oauth2/v2.0/token`,
|
||||||
|
method: "POST",
|
||||||
|
contentType: "application/x-www-form-urlencoded",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
tenant: "consumers",
|
||||||
|
client_id: clientID,
|
||||||
|
scope: SCOPES.join(" "),
|
||||||
|
code: authCode,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code_verifier: verifier,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rsp2 = JSON.parse(rsp1);
|
||||||
|
// log.info(rsp2);
|
||||||
|
|
||||||
|
if (rsp2.error !== undefined) {
|
||||||
|
return rsp2 as AccessCodeResponseFailedType;
|
||||||
|
} else {
|
||||||
|
return rsp2 as AccessCodeResponseSuccessfulType;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendRefreshTokenReq = async (
|
||||||
|
clientID: string,
|
||||||
|
authority: string,
|
||||||
|
refreshToken: string
|
||||||
|
) => {
|
||||||
|
// also use Obsidian request to bypass CORS issue.
|
||||||
|
const rsp1 = await request({
|
||||||
|
url: `${authority}/oauth2/v2.0/token`,
|
||||||
|
method: "POST",
|
||||||
|
contentType: "application/x-www-form-urlencoded",
|
||||||
|
body: new URLSearchParams({
|
||||||
|
tenant: "consumers",
|
||||||
|
client_id: clientID,
|
||||||
|
scope: SCOPES.join(" "),
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const rsp2 = JSON.parse(rsp1);
|
||||||
|
// log.info(rsp2);
|
||||||
|
|
||||||
|
if (rsp2.error !== undefined) {
|
||||||
|
return rsp2 as AccessCodeResponseFailedType;
|
||||||
|
} else {
|
||||||
|
return rsp2 as AccessCodeResponseSuccessfulType;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setConfigBySuccessfullAuthInplace = async (
|
||||||
|
config: OnedriveConfig,
|
||||||
|
authRes: AccessCodeResponseSuccessfulType,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||||
|
) => {
|
||||||
|
log.info("start updating local info of OneDrive token");
|
||||||
|
config.accessToken = authRes.access_token;
|
||||||
|
config.accessTokenExpiresAtTime =
|
||||||
|
Date.now() + authRes.expires_in - 5 * 60 * 1000;
|
||||||
|
config.accessTokenExpiresInSeconds = authRes.expires_in;
|
||||||
|
config.refreshToken = authRes.refresh_token;
|
||||||
|
|
||||||
|
// manually set it expired after 80 days;
|
||||||
|
config.credentialsShouldBeDeletedAtTime =
|
||||||
|
Date.now() + OAUTH2_FORCE_EXPIRE_MILLISECONDS;
|
||||||
|
|
||||||
|
if (saveUpdatedConfigFunc !== undefined) {
|
||||||
|
await saveUpdatedConfigFunc();
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("finish updating local info of Onedrive token");
|
||||||
|
};
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Other usual common methods
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
const getOnedrivePath = (fileOrFolderPath: string, vaultName: string) => {
|
||||||
|
// https://docs.microsoft.com/en-us/onedrive/developer/rest-api/concepts/special-folders-appfolder?view=odsp-graph-online
|
||||||
|
const prefix = `/drive/special/approot:/${vaultName}`;
|
||||||
|
if (fileOrFolderPath.startsWith(prefix)) {
|
||||||
|
// already transformed, return as is
|
||||||
|
return fileOrFolderPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = fileOrFolderPath;
|
||||||
|
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||||
|
// special
|
||||||
|
return prefix;
|
||||||
|
}
|
||||||
|
if (key.endsWith("/")) {
|
||||||
|
key = key.slice(0, key.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
key = `${prefix}/${key}`;
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNormPath = (fileOrFolderPath: string, vaultName: string) => {
|
||||||
|
const prefix = `/drive/special/approot:/${vaultName}`;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!(fileOrFolderPath === prefix || fileOrFolderPath.startsWith(`${prefix}/`))
|
||||||
|
) {
|
||||||
|
throw Error(
|
||||||
|
`"${fileOrFolderPath}" doesn't starts with "${prefix}/" or equals to "${prefix}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileOrFolderPath === prefix) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
return fileOrFolderPath.slice(`${prefix}/`.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const constructFromDriveItemToRemoteItemError = (x: DriveItem) => {
|
||||||
|
return `parentPath="${x.parentReference.path}", selfName="${x.name}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromDriveItemToRemoteItem = (
|
||||||
|
x: DriveItem,
|
||||||
|
vaultName: string
|
||||||
|
): RemoteItem => {
|
||||||
|
let key = "";
|
||||||
|
|
||||||
|
// possible prefix:
|
||||||
|
// pure english: /drive/root:/Apps/remotely-save/${vaultName}
|
||||||
|
// or localized, e.g.: /drive/root:/应用/remotely-save/${vaultName}
|
||||||
|
const FIRST_COMMON_PREFIX_REGEX = /^\/drive\/root:\/[^\/]+\/remotely-save\//g;
|
||||||
|
|
||||||
|
// another possibile prefix
|
||||||
|
const SECOND_COMMON_PREFIX_RAW = `/drive/items/`;
|
||||||
|
|
||||||
|
const fullPathOriginal = `${x.parentReference.path}/${x.name}`;
|
||||||
|
const matchFirstPrefixRes = fullPathOriginal.match(FIRST_COMMON_PREFIX_REGEX);
|
||||||
|
if (
|
||||||
|
matchFirstPrefixRes !== null &&
|
||||||
|
fullPathOriginal.startsWith(`${matchFirstPrefixRes[0]}${vaultName}`)
|
||||||
|
) {
|
||||||
|
const foundPrefix = `${matchFirstPrefixRes[0]}${vaultName}`;
|
||||||
|
key = fullPathOriginal.substring(foundPrefix.length + 1);
|
||||||
|
} else if (x.parentReference.path.startsWith(SECOND_COMMON_PREFIX_RAW)) {
|
||||||
|
// it's something like
|
||||||
|
// /drive/items/<some_id>!<another_id>:/${vaultName}/<subfolder>
|
||||||
|
// with uri encoded!
|
||||||
|
const parPath = decodeURIComponent(x.parentReference.path);
|
||||||
|
key = parPath.substring(parPath.indexOf(":") + 1);
|
||||||
|
if (key.startsWith(`/${vaultName}/`)) {
|
||||||
|
key = key.substring(`/${vaultName}/`.length);
|
||||||
|
key = `${key}/${x.name}`;
|
||||||
|
} else if (key === `/${vaultName}`) {
|
||||||
|
key = x.name;
|
||||||
|
} else {
|
||||||
|
throw Error(
|
||||||
|
`we meet file/folder and do not know how to deal with it:\n${constructFromDriveItemToRemoteItemError(
|
||||||
|
x
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw Error(
|
||||||
|
`we meet file/folder and do not know how to deal with it:\n${constructFromDriveItemToRemoteItemError(
|
||||||
|
x
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFolder = "folder" in x;
|
||||||
|
if (isFolder) {
|
||||||
|
key = `${key}/`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
lastModified: Date.parse(x.fileSystemInfo.lastModifiedDateTime),
|
||||||
|
size: isFolder ? 0 : x.size,
|
||||||
|
remoteType: "onedrive",
|
||||||
|
etag: x.eTag || x.cTag || "",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// to adapt to the required interface
|
||||||
|
class MyAuthProvider implements AuthenticationProvider {
|
||||||
|
onedriveConfig: OnedriveConfig;
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>;
|
||||||
|
constructor(
|
||||||
|
onedriveConfig: OnedriveConfig,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>
|
||||||
|
) {
|
||||||
|
this.onedriveConfig = onedriveConfig;
|
||||||
|
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||||
|
}
|
||||||
|
getAccessToken = async () => {
|
||||||
|
if (
|
||||||
|
this.onedriveConfig.accessToken === "" ||
|
||||||
|
this.onedriveConfig.refreshToken === ""
|
||||||
|
) {
|
||||||
|
throw Error("The user has not manually auth yet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTs = Date.now();
|
||||||
|
if (this.onedriveConfig.accessTokenExpiresAtTime > currentTs) {
|
||||||
|
return this.onedriveConfig.accessToken;
|
||||||
|
} else {
|
||||||
|
// use refreshToken to refresh
|
||||||
|
const r = await sendRefreshTokenReq(
|
||||||
|
this.onedriveConfig.clientID,
|
||||||
|
this.onedriveConfig.authority,
|
||||||
|
this.onedriveConfig.refreshToken
|
||||||
|
);
|
||||||
|
if ((r as any).error !== undefined) {
|
||||||
|
const r2 = r as AccessCodeResponseFailedType;
|
||||||
|
throw Error(
|
||||||
|
`Error while refreshing accessToken: ${r2.error}, ${r2.error_codes}: ${r2.error_description}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const r2 = r as AccessCodeResponseSuccessfulType;
|
||||||
|
this.onedriveConfig.accessToken = r2.access_token;
|
||||||
|
this.onedriveConfig.refreshToken = r2.refresh_token;
|
||||||
|
this.onedriveConfig.accessTokenExpiresInSeconds = r2.expires_in;
|
||||||
|
this.onedriveConfig.accessTokenExpiresAtTime =
|
||||||
|
currentTs + r2.expires_in * 1000 - 60 * 2 * 1000;
|
||||||
|
await this.saveUpdatedConfigFunc();
|
||||||
|
log.info("Onedrive accessToken updated");
|
||||||
|
return this.onedriveConfig.accessToken;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WrappedOnedriveClient {
|
||||||
|
onedriveConfig: OnedriveConfig;
|
||||||
|
vaultName: string;
|
||||||
|
client: Client;
|
||||||
|
vaultFolderExists: boolean;
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>;
|
||||||
|
constructor(
|
||||||
|
onedriveConfig: OnedriveConfig,
|
||||||
|
vaultName: string,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>
|
||||||
|
) {
|
||||||
|
this.onedriveConfig = onedriveConfig;
|
||||||
|
this.vaultName = vaultName;
|
||||||
|
this.vaultFolderExists = false;
|
||||||
|
this.saveUpdatedConfigFunc = saveUpdatedConfigFunc;
|
||||||
|
this.client = Client.initWithMiddleware({
|
||||||
|
authProvider: new MyAuthProvider(onedriveConfig, saveUpdatedConfigFunc),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init = async () => {
|
||||||
|
// check token
|
||||||
|
if (
|
||||||
|
this.onedriveConfig.accessToken === "" ||
|
||||||
|
this.onedriveConfig.refreshToken === ""
|
||||||
|
) {
|
||||||
|
throw Error("The user has not manually auth yet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// check vault folder
|
||||||
|
// log.info(`checking remote has folder /${this.vaultName}`);
|
||||||
|
if (this.vaultFolderExists) {
|
||||||
|
// log.info(`already checked, /${this.vaultName} exist before`)
|
||||||
|
} else {
|
||||||
|
const k = await this.client.api("/drive/special/approot/children").get();
|
||||||
|
// log.info(k);
|
||||||
|
this.vaultFolderExists =
|
||||||
|
(k.value as DriveItem[]).filter((x) => x.name === this.vaultName)
|
||||||
|
.length > 0;
|
||||||
|
if (!this.vaultFolderExists) {
|
||||||
|
log.info(`remote does not have folder /${this.vaultName}`);
|
||||||
|
await this.client.api("/drive/special/approot/children").post({
|
||||||
|
name: `${this.vaultName}`,
|
||||||
|
folder: {},
|
||||||
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
|
});
|
||||||
|
log.info(`remote folder /${this.vaultName} created`);
|
||||||
|
this.vaultFolderExists = true;
|
||||||
|
} else {
|
||||||
|
// log.info(`remote folder /${this.vaultName} exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getOnedriveClient = (
|
||||||
|
onedriveConfig: OnedriveConfig,
|
||||||
|
vaultName: string,
|
||||||
|
saveUpdatedConfigFunc: () => Promise<any>
|
||||||
|
) => {
|
||||||
|
return new WrappedOnedriveClient(
|
||||||
|
onedriveConfig,
|
||||||
|
vaultName,
|
||||||
|
saveUpdatedConfigFunc
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use delta api to list all files and folders
|
||||||
|
* https://docs.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delta?view=odsp-graph-online
|
||||||
|
* @param client
|
||||||
|
* @param prefix
|
||||||
|
*/
|
||||||
|
export const listFromRemote = async (
|
||||||
|
client: WrappedOnedriveClient,
|
||||||
|
prefix?: string
|
||||||
|
) => {
|
||||||
|
if (prefix !== undefined) {
|
||||||
|
throw Error("prefix not supported (yet)");
|
||||||
|
}
|
||||||
|
await client.init();
|
||||||
|
|
||||||
|
const NEXT_LINK_KEY = "@odata.nextLink";
|
||||||
|
const DELTA_LINK_KEY = "@odata.deltaLink";
|
||||||
|
let res = await client.client
|
||||||
|
.api(`/drive/special/approot:/${client.vaultName}:/delta`)
|
||||||
|
.get();
|
||||||
|
let driveItems = res.value as DriveItem[];
|
||||||
|
|
||||||
|
while (NEXT_LINK_KEY in res) {
|
||||||
|
res = await client.client.api(res[NEXT_LINK_KEY]).get();
|
||||||
|
driveItems.push(...cloneDeep(res.value as DriveItem[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastly we should have delta link?
|
||||||
|
if (DELTA_LINK_KEY in res) {
|
||||||
|
client.onedriveConfig.deltaLink = res[DELTA_LINK_KEY];
|
||||||
|
await client.saveUpdatedConfigFunc();
|
||||||
|
}
|
||||||
|
|
||||||
|
driveItems = driveItems.map((x) => {
|
||||||
|
const y = cloneDeep(x);
|
||||||
|
y.parentReference.path = y.parentReference.path.replace("/Apps", "/应用");
|
||||||
|
return y;
|
||||||
|
});
|
||||||
|
|
||||||
|
// unify everything to RemoteItem
|
||||||
|
const unifiedContents = driveItems
|
||||||
|
.map((x) => fromDriveItemToRemoteItem(x, client.vaultName))
|
||||||
|
.filter((x) => x.key !== "/");
|
||||||
|
return {
|
||||||
|
Contents: unifiedContents,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRemoteMeta = async (
|
||||||
|
client: WrappedOnedriveClient,
|
||||||
|
fileOrFolderPath: string
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
const remotePath = getOnedrivePath(fileOrFolderPath, client.vaultName);
|
||||||
|
// log.info(`remotePath=${remotePath}`);
|
||||||
|
const rsp = await client.client
|
||||||
|
.api(remotePath)
|
||||||
|
.select("cTag,eTag,fileSystemInfo,folder,file,name,parentReference,size")
|
||||||
|
.get();
|
||||||
|
// log.info(rsp);
|
||||||
|
const driveItem = rsp as DriveItem;
|
||||||
|
const res = fromDriveItemToRemoteItem(driveItem, client.vaultName);
|
||||||
|
// log.info(res);
|
||||||
|
return res;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadToRemote = async (
|
||||||
|
client: WrappedOnedriveClient,
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
vault: Vault,
|
||||||
|
isRecursively: boolean = false,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = "",
|
||||||
|
foldersCreatedBefore: Set<string> | undefined = undefined
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
|
||||||
|
let uploadFile = fileOrFolderPath;
|
||||||
|
if (password !== "") {
|
||||||
|
uploadFile = remoteEncryptedKey;
|
||||||
|
}
|
||||||
|
uploadFile = getOnedrivePath(uploadFile, client.vaultName);
|
||||||
|
// log.info(`uploadFile=${uploadFile}`);
|
||||||
|
|
||||||
|
const isFolder = fileOrFolderPath.endsWith("/");
|
||||||
|
|
||||||
|
if (isFolder && isRecursively) {
|
||||||
|
throw Error("upload function doesn't implement recursive function yet!");
|
||||||
|
} else if (isFolder && !isRecursively) {
|
||||||
|
// folder
|
||||||
|
if (password === "") {
|
||||||
|
// if not encrypted, mkdir a remote folder
|
||||||
|
if (foldersCreatedBefore?.has(uploadFile)) {
|
||||||
|
// created, pass
|
||||||
|
} else {
|
||||||
|
// https://stackoverflow.com/questions/56479865/creating-nested-folders-in-one-go-onedrive-api
|
||||||
|
// use PATCH to create folder recursively!!!
|
||||||
|
await client.client.api(uploadFile).patch({
|
||||||
|
folder: {},
|
||||||
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const res = await getRemoteMeta(client, uploadFile);
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
// if encrypted,
|
||||||
|
// upload a fake, random-size file
|
||||||
|
// with the encrypted file name
|
||||||
|
const byteLengthRandom = getRandomIntInclusive(
|
||||||
|
1,
|
||||||
|
65536 /* max allowed */
|
||||||
|
);
|
||||||
|
const arrBufRandom = await encryptArrayBuffer(
|
||||||
|
getRandomArrayBuffer(byteLengthRandom),
|
||||||
|
password
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadSession: LargeFileUploadSession =
|
||||||
|
await LargeFileUploadTask.createUploadSession(
|
||||||
|
client.client,
|
||||||
|
`https://graph.microsoft.com/v1.0/me${encodeURIComponent(
|
||||||
|
uploadFile
|
||||||
|
)}:/createUploadSession`,
|
||||||
|
{
|
||||||
|
item: {
|
||||||
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const task = new LargeFileUploadTask(
|
||||||
|
client.client,
|
||||||
|
new FileUpload(
|
||||||
|
arrBufRandom,
|
||||||
|
path.posix.basename(uploadFile),
|
||||||
|
arrBufRandom.byteLength
|
||||||
|
),
|
||||||
|
uploadSession,
|
||||||
|
{
|
||||||
|
rangeSize: 1024 * 1024,
|
||||||
|
uploadEventHandlers: {
|
||||||
|
progress: (range?: Range) => {
|
||||||
|
// Handle progress event
|
||||||
|
// log.info(
|
||||||
|
// `uploading ${range.minValue}-${range.maxValue} of ${fileOrFolderPath}`
|
||||||
|
// );
|
||||||
|
},
|
||||||
|
} as UploadEventHandlers,
|
||||||
|
} as LargeFileUploadTaskOptions
|
||||||
|
);
|
||||||
|
const uploadResult: UploadResult = await task.upload();
|
||||||
|
// log.info(uploadResult)
|
||||||
|
const res = await getRemoteMeta(client, uploadFile);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// file
|
||||||
|
// we ignore isRecursively parameter here
|
||||||
|
const localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
||||||
|
let remoteContent = localContent;
|
||||||
|
if (password !== "") {
|
||||||
|
remoteContent = await encryptArrayBuffer(localContent, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// no need to create parent folders firstly, cool!
|
||||||
|
|
||||||
|
// we need to customize the special root folder,
|
||||||
|
// so use LargeFileUploadTask instead of OneDriveLargeFileUploadTask
|
||||||
|
const progress = (range?: Range) => {
|
||||||
|
// Handle progress event
|
||||||
|
// log.info(
|
||||||
|
// `uploading ${range.minValue}-${range.maxValue} of ${fileOrFolderPath}`
|
||||||
|
// );
|
||||||
|
};
|
||||||
|
const uploadEventHandlers: UploadEventHandlers = {
|
||||||
|
progress: progress,
|
||||||
|
};
|
||||||
|
const options: LargeFileUploadTaskOptions = {
|
||||||
|
rangeSize: 1024 * 1024,
|
||||||
|
uploadEventHandlers: uploadEventHandlers,
|
||||||
|
};
|
||||||
|
const payload = {
|
||||||
|
item: {
|
||||||
|
"@microsoft.graph.conflictBehavior": "replace",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// uploadFile already starts with /drive/special/approot:/${vaultName}
|
||||||
|
const uploadSession: LargeFileUploadSession =
|
||||||
|
await LargeFileUploadTask.createUploadSession(
|
||||||
|
client.client,
|
||||||
|
`https://graph.microsoft.com/v1.0/me${encodeURIComponent(
|
||||||
|
uploadFile
|
||||||
|
)}:/createUploadSession`,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
const fileObject = new FileUpload(
|
||||||
|
remoteContent,
|
||||||
|
path.posix.basename(uploadFile),
|
||||||
|
remoteContent.byteLength
|
||||||
|
);
|
||||||
|
const task = new LargeFileUploadTask(
|
||||||
|
client.client,
|
||||||
|
fileObject,
|
||||||
|
uploadSession,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
const uploadResult: UploadResult = await task.upload();
|
||||||
|
// log.info(uploadResult)
|
||||||
|
const res = await getRemoteMeta(client, uploadFile);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFromRemoteRaw = async (
|
||||||
|
client: WrappedOnedriveClient,
|
||||||
|
fileOrFolderPath: string
|
||||||
|
): Promise<ArrayBuffer> => {
|
||||||
|
await client.init();
|
||||||
|
const key = getOnedrivePath(fileOrFolderPath, client.vaultName);
|
||||||
|
const rsp = await client.client
|
||||||
|
.api(key)
|
||||||
|
.select("@microsoft.graph.downloadUrl")
|
||||||
|
.get();
|
||||||
|
const downloadUrl: string = rsp["@microsoft.graph.downloadUrl"];
|
||||||
|
const content = await (await fetch(downloadUrl)).arrayBuffer();
|
||||||
|
return content;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadFromRemote = async (
|
||||||
|
client: WrappedOnedriveClient,
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
vault: Vault,
|
||||||
|
mtime: number,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = ""
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
|
||||||
|
const isFolder = fileOrFolderPath.endsWith("/");
|
||||||
|
|
||||||
|
await mkdirpInVault(fileOrFolderPath, vault);
|
||||||
|
|
||||||
|
if (isFolder) {
|
||||||
|
// mkdirp locally is enough
|
||||||
|
// do nothing here
|
||||||
|
} else {
|
||||||
|
let downloadFile = fileOrFolderPath;
|
||||||
|
if (password !== "") {
|
||||||
|
downloadFile = remoteEncryptedKey;
|
||||||
|
}
|
||||||
|
downloadFile = getOnedrivePath(downloadFile, client.vaultName);
|
||||||
|
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
||||||
|
let localContent = remoteContent;
|
||||||
|
if (password !== "") {
|
||||||
|
localContent = await decryptArrayBuffer(remoteContent, password);
|
||||||
|
}
|
||||||
|
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||||
|
mtime: mtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFromRemote = async (
|
||||||
|
client: WrappedOnedriveClient,
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = ""
|
||||||
|
) => {
|
||||||
|
if (fileOrFolderPath === "/") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let remoteFileName = fileOrFolderPath;
|
||||||
|
if (password !== "") {
|
||||||
|
remoteFileName = remoteEncryptedKey;
|
||||||
|
}
|
||||||
|
remoteFileName = getOnedrivePath(remoteFileName, client.vaultName);
|
||||||
|
|
||||||
|
await client.init();
|
||||||
|
await client.client.api(remoteFileName).delete();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkConnectivity = async (client: WrappedOnedriveClient) => {
|
||||||
|
try {
|
||||||
|
const k = await getUserDisplayName(client);
|
||||||
|
return k !== "<unknown display name>";
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserDisplayName = async (client: WrappedOnedriveClient) => {
|
||||||
|
await client.init();
|
||||||
|
const res: User = await client.client.api("/me").select("displayName").get();
|
||||||
|
return res.displayName || "<unknown display name>";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#send-a-sign-out-request
|
||||||
|
* https://docs.microsoft.com/en-us/graph/api/user-revokesigninsessions
|
||||||
|
* https://docs.microsoft.com/en-us/graph/api/user-invalidateallrefreshtokens
|
||||||
|
* @param client
|
||||||
|
*/
|
||||||
|
// export const revokeAuth = async (client: WrappedOnedriveClient) => {
|
||||||
|
// await client.init();
|
||||||
|
// await client.client.api('/me/revokeSignInSessions').post(undefined);
|
||||||
|
// };
|
||||||
|
|
||||||
|
export const getRevokeAddr = async () => {
|
||||||
|
return "https://account.live.com/consent/Manage";
|
||||||
|
};
|
||||||
+139
-49
@@ -1,34 +1,32 @@
|
|||||||
import { Buffer } from "buffer";
|
|
||||||
import { Readable } from "stream";
|
|
||||||
|
|
||||||
import { Vault } from "obsidian";
|
|
||||||
|
|
||||||
import {
|
|
||||||
S3Client,
|
|
||||||
ListObjectsV2Command,
|
|
||||||
PutObjectCommand,
|
|
||||||
GetObjectCommand,
|
|
||||||
DeleteObjectCommand,
|
|
||||||
HeadObjectCommand,
|
|
||||||
} 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 { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
|
||||||
|
|
||||||
export interface S3Config {
|
export { S3Client } from "@aws-sdk/client-s3";
|
||||||
s3Endpoint: string;
|
|
||||||
s3Region: string;
|
import * as origLog from "loglevel";
|
||||||
s3AccessKeyID: string;
|
const log = origLog.getLogger("rs-default");
|
||||||
s3SecretAccessKey: string;
|
|
||||||
s3BucketName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEFAULT_S3_CONFIG = {
|
export const DEFAULT_S3_CONFIG = {
|
||||||
s3Endpoint: "",
|
s3Endpoint: "",
|
||||||
@@ -40,10 +38,37 @@ export const DEFAULT_S3_CONFIG = {
|
|||||||
|
|
||||||
export type S3ObjectType = _Object;
|
export type S3ObjectType = _Object;
|
||||||
|
|
||||||
|
const fromS3ObjectToRemoteItem = (x: S3ObjectType) => {
|
||||||
|
return {
|
||||||
|
key: x.Key,
|
||||||
|
lastModified: x.LastModified.valueOf(),
|
||||||
|
size: x.Size,
|
||||||
|
remoteType: "s3",
|
||||||
|
etag: x.ETag,
|
||||||
|
} as RemoteItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromS3HeadObjectToRemoteItem = (
|
||||||
|
key: string,
|
||||||
|
x: HeadObjectCommandOutput
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
lastModified: x.LastModified.valueOf(),
|
||||||
|
size: x.ContentLength,
|
||||||
|
remoteType: "s3",
|
||||||
|
etag: x.ETag,
|
||||||
|
} as RemoteItem;
|
||||||
|
};
|
||||||
|
|
||||||
export const getS3Client = (s3Config: S3Config) => {
|
export const getS3Client = (s3Config: S3Config) => {
|
||||||
|
let endpoint = s3Config.s3Endpoint;
|
||||||
|
if (!(endpoint.startsWith("http://") || endpoint.startsWith("https://"))) {
|
||||||
|
endpoint = `https://${endpoint}`;
|
||||||
|
}
|
||||||
const s3Client = new S3Client({
|
const s3Client = new S3Client({
|
||||||
region: s3Config.s3Region,
|
region: s3Config.s3Region,
|
||||||
endpoint: s3Config.s3Endpoint,
|
endpoint: endpoint,
|
||||||
credentials: {
|
credentials: {
|
||||||
accessKeyId: s3Config.s3AccessKeyID,
|
accessKeyId: s3Config.s3AccessKeyID,
|
||||||
secretAccessKey: s3Config.s3SecretAccessKey,
|
secretAccessKey: s3Config.s3SecretAccessKey,
|
||||||
@@ -57,12 +82,14 @@ export const getRemoteMeta = async (
|
|||||||
s3Config: S3Config,
|
s3Config: S3Config,
|
||||||
fileOrFolderPath: string
|
fileOrFolderPath: string
|
||||||
) => {
|
) => {
|
||||||
return await s3Client.send(
|
const res = await s3Client.send(
|
||||||
new HeadObjectCommand({
|
new HeadObjectCommand({
|
||||||
Bucket: s3Config.s3BucketName,
|
Bucket: s3Config.s3BucketName,
|
||||||
Key: fileOrFolderPath,
|
Key: fileOrFolderPath,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return fromS3HeadObjectToRemoteItem(fileOrFolderPath, res);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadToRemote = async (
|
export const uploadToRemote = async (
|
||||||
@@ -95,7 +122,7 @@ export const uploadToRemote = async (
|
|||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return await getRemoteMeta(s3Client, s3Config, fileOrFolderPath);
|
return await getRemoteMeta(s3Client, s3Config, uploadFile);
|
||||||
} else {
|
} else {
|
||||||
// file
|
// file
|
||||||
// we ignore isRecursively parameter here
|
// we ignore isRecursively parameter here
|
||||||
@@ -109,17 +136,27 @@ export const uploadToRemote = async (
|
|||||||
const localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
const localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
||||||
let remoteContent = localContent;
|
let remoteContent = localContent;
|
||||||
if (password !== "") {
|
if (password !== "") {
|
||||||
remoteContent = encryptArrayBuffer(localContent, password);
|
remoteContent = await encryptArrayBuffer(localContent, password);
|
||||||
}
|
}
|
||||||
const body = arrayBufferToBuffer(remoteContent);
|
const body = arrayBufferToBuffer(remoteContent);
|
||||||
await s3Client.send(
|
|
||||||
new PutObjectCommand({
|
const upload = new Upload({
|
||||||
|
client: s3Client,
|
||||||
|
queueSize: 20, // concurrency
|
||||||
|
partSize: 5242880, // minimal 5MB by default
|
||||||
|
leavePartsOnError: false,
|
||||||
|
params: {
|
||||||
Bucket: s3Config.s3BucketName,
|
Bucket: s3Config.s3BucketName,
|
||||||
Key: uploadFile,
|
Key: uploadFile,
|
||||||
Body: body,
|
Body: body,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
})
|
},
|
||||||
);
|
});
|
||||||
|
upload.on("httpUploadProgress", (progress) => {
|
||||||
|
// log.info(progress);
|
||||||
|
});
|
||||||
|
await upload.done();
|
||||||
|
|
||||||
return await getRemoteMeta(s3Client, s3Config, uploadFile);
|
return await getRemoteMeta(s3Client, s3Config, uploadFile);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -129,17 +166,42 @@ export const listFromRemote = async (
|
|||||||
s3Config: S3Config,
|
s3Config: S3Config,
|
||||||
prefix?: string
|
prefix?: string
|
||||||
) => {
|
) => {
|
||||||
if (prefix !== undefined) {
|
const confCmd = {
|
||||||
return await s3Client.send(
|
|
||||||
new ListObjectsV2Command({
|
|
||||||
Bucket: s3Config.s3BucketName,
|
Bucket: s3Config.s3BucketName,
|
||||||
Prefix: prefix,
|
} as ListObjectsV2CommandInput;
|
||||||
})
|
if (prefix !== undefined) {
|
||||||
);
|
confCmd.Prefix = prefix;
|
||||||
}
|
}
|
||||||
return await s3Client.send(
|
|
||||||
new ListObjectsV2Command({ Bucket: s3Config.s3BucketName })
|
const contents = [] as _Object[];
|
||||||
);
|
|
||||||
|
let isTruncated = true;
|
||||||
|
let continuationToken = "";
|
||||||
|
do {
|
||||||
|
const rsp = await s3Client.send(new ListObjectsV2Command(confCmd));
|
||||||
|
|
||||||
|
if (rsp.$metadata.httpStatusCode !== 200) {
|
||||||
|
throw Error("some thing bad while listing remote!");
|
||||||
|
}
|
||||||
|
if (rsp.Contents === undefined) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
contents.push(...rsp.Contents);
|
||||||
|
|
||||||
|
isTruncated = rsp.IsTruncated;
|
||||||
|
confCmd.ContinuationToken = rsp.NextContinuationToken;
|
||||||
|
if (
|
||||||
|
isTruncated &&
|
||||||
|
(continuationToken === undefined || continuationToken === "")
|
||||||
|
) {
|
||||||
|
throw Error("isTruncated is true but no continuationToken provided");
|
||||||
|
}
|
||||||
|
} while (isTruncated);
|
||||||
|
|
||||||
|
// ensemble fake rsp
|
||||||
|
return {
|
||||||
|
Contents: contents.map((x) => fromS3ObjectToRemoteItem(x)),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,12 +215,12 @@ const getObjectBodyToArrayBuffer = async (
|
|||||||
b: Readable | ReadableStream | Blob
|
b: Readable | ReadableStream | Blob
|
||||||
) => {
|
) => {
|
||||||
if (b instanceof Readable) {
|
if (b instanceof Readable) {
|
||||||
|
return (await new Promise((resolve, reject) => {
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
for await (let chunk of b) {
|
b.on("data", (chunk) => chunks.push(chunk));
|
||||||
chunks.push(chunk);
|
b.on("error", reject);
|
||||||
}
|
b.on("end", () => resolve(bufferToArrayBuffer(Buffer.concat(chunks))));
|
||||||
const buf = Buffer.concat(chunks);
|
})) as ArrayBuffer;
|
||||||
return bufferToArrayBuffer(buf);
|
|
||||||
} else if (b instanceof ReadableStream) {
|
} else if (b instanceof ReadableStream) {
|
||||||
return await new Response(b, {}).arrayBuffer();
|
return await new Response(b, {}).arrayBuffer();
|
||||||
} else if (b instanceof Blob) {
|
} else if (b instanceof Blob) {
|
||||||
@@ -168,7 +230,7 @@ const getObjectBodyToArrayBuffer = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const downloadFromRemoteRaw = async (
|
const downloadFromRemoteRaw = async (
|
||||||
s3Client: S3Client,
|
s3Client: S3Client,
|
||||||
s3Config: S3Config,
|
s3Config: S3Config,
|
||||||
fileOrFolderPath: string
|
fileOrFolderPath: string
|
||||||
@@ -214,7 +276,7 @@ export const downloadFromRemote = async (
|
|||||||
);
|
);
|
||||||
let localContent = remoteContent;
|
let localContent = remoteContent;
|
||||||
if (password !== "") {
|
if (password !== "") {
|
||||||
localContent = decryptArrayBuffer(remoteContent, password);
|
localContent = await decryptArrayBuffer(remoteContent, password);
|
||||||
}
|
}
|
||||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||||
mtime: mtime,
|
mtime: mtime,
|
||||||
@@ -256,7 +318,7 @@ export const deleteFromRemote = async (
|
|||||||
await s3Client.send(
|
await s3Client.send(
|
||||||
new DeleteObjectCommand({
|
new DeleteObjectCommand({
|
||||||
Bucket: s3Config.s3BucketName,
|
Bucket: s3Config.s3BucketName,
|
||||||
Key: element.Key,
|
Key: element.key,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -266,3 +328,31 @@ export const deleteFromRemote = async (
|
|||||||
// pass
|
// pass
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the config of S3 by heading bucket
|
||||||
|
* https://stackoverflow.com/questions/50842835
|
||||||
|
* @param s3Client
|
||||||
|
* @param s3Config
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const checkConnectivity = async (
|
||||||
|
s3Client: S3Client,
|
||||||
|
s3Config: S3Config
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const results = await s3Client.send(
|
||||||
|
new HeadBucketCommand({ Bucket: s3Config.s3BucketName })
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
results === undefined ||
|
||||||
|
results.$metadata === undefined ||
|
||||||
|
results.$metadata.httpStatusCode === undefined
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return results.$metadata.httpStatusCode === 200;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { Buffer } from "buffer";
|
||||||
|
import { Vault } from "obsidian";
|
||||||
|
import type { FileStat, WebDAVClient } from "webdav/web";
|
||||||
|
import { AuthType, BufferLike, createClient } from "webdav/web";
|
||||||
|
import type { RemoteItem, WebdavConfig } from "./baseTypes";
|
||||||
|
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
||||||
|
import { bufferToArrayBuffer, getPathFolder, mkdirpInVault } from "./misc";
|
||||||
|
export type { WebDAVClient } from "webdav/web";
|
||||||
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
|
export const DEFAULT_WEBDAV_CONFIG = {
|
||||||
|
address: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
authType: "basic",
|
||||||
|
} as WebdavConfig;
|
||||||
|
|
||||||
|
const getWebdavPath = (fileOrFolderPath: string, vaultName: string) => {
|
||||||
|
let key = fileOrFolderPath;
|
||||||
|
if (fileOrFolderPath === "/" || fileOrFolderPath === "") {
|
||||||
|
// special
|
||||||
|
key = `/${vaultName}/`;
|
||||||
|
}
|
||||||
|
if (!fileOrFolderPath.startsWith("/")) {
|
||||||
|
key = `/${vaultName}/${fileOrFolderPath}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNormPath = (fileOrFolderPath: string, vaultName: string) => {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
fileOrFolderPath === `/${vaultName}` ||
|
||||||
|
fileOrFolderPath.startsWith(`/${vaultName}/`)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw Error(`"${fileOrFolderPath}" doesn't starts with "/${vaultName}/"`);
|
||||||
|
}
|
||||||
|
// if (fileOrFolderPath.startsWith("/")) {
|
||||||
|
// return fileOrFolderPath.slice(1);
|
||||||
|
// }
|
||||||
|
return fileOrFolderPath.slice(`/${vaultName}/`.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromWebdavItemToRemoteItem = (x: FileStat, vaultName: string) => {
|
||||||
|
let key = getNormPath(x.filename, vaultName);
|
||||||
|
if (x.type === "directory" && !key.endsWith("/")) {
|
||||||
|
key = `${key}/`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
lastModified: Date.parse(x.lastmod).valueOf(),
|
||||||
|
size: x.size,
|
||||||
|
remoteType: "webdav",
|
||||||
|
etag: x.etag || undefined,
|
||||||
|
} as RemoteItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class WrappedWebdavClient {
|
||||||
|
webdavConfig: WebdavConfig;
|
||||||
|
vaultName: string;
|
||||||
|
client: WebDAVClient;
|
||||||
|
vaultFolderExists: boolean;
|
||||||
|
constructor(webdavConfig: WebdavConfig, vaultName: string) {
|
||||||
|
this.webdavConfig = webdavConfig;
|
||||||
|
this.vaultName = vaultName;
|
||||||
|
this.vaultFolderExists = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
init = async () => {
|
||||||
|
// init client if not inited
|
||||||
|
if (this.client === undefined) {
|
||||||
|
if (
|
||||||
|
this.webdavConfig.username !== "" &&
|
||||||
|
this.webdavConfig.password !== ""
|
||||||
|
) {
|
||||||
|
this.client = createClient(this.webdavConfig.address, {
|
||||||
|
username: this.webdavConfig.username,
|
||||||
|
password: this.webdavConfig.password,
|
||||||
|
authType:
|
||||||
|
this.webdavConfig.authType === "digest"
|
||||||
|
? AuthType.Digest
|
||||||
|
: AuthType.Password,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.info("no password");
|
||||||
|
this.client = createClient(this.webdavConfig.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check vault folder
|
||||||
|
if (this.vaultFolderExists) {
|
||||||
|
// pass
|
||||||
|
} else {
|
||||||
|
const res = await this.client.exists(`/${this.vaultName}`);
|
||||||
|
if (res) {
|
||||||
|
// log.info("remote vault folder exits!");
|
||||||
|
this.vaultFolderExists = true;
|
||||||
|
} else {
|
||||||
|
log.info("remote vault folder not exists, creating");
|
||||||
|
await this.client.createDirectory(`/${this.vaultName}`);
|
||||||
|
log.info("remote vault folder created!");
|
||||||
|
this.vaultFolderExists = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getWebdavClient = (
|
||||||
|
webdavConfig: WebdavConfig,
|
||||||
|
vaultName: string
|
||||||
|
) => {
|
||||||
|
return new WrappedWebdavClient(webdavConfig, vaultName);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRemoteMeta = async (
|
||||||
|
client: WrappedWebdavClient,
|
||||||
|
fileOrFolderPath: string
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
const remotePath = getWebdavPath(fileOrFolderPath, client.vaultName);
|
||||||
|
// log.info(`remotePath = ${remotePath}`);
|
||||||
|
const res = (await client.client.stat(remotePath, {
|
||||||
|
details: false,
|
||||||
|
})) as FileStat;
|
||||||
|
return fromWebdavItemToRemoteItem(res, client.vaultName);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadToRemote = async (
|
||||||
|
client: WrappedWebdavClient,
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
vault: Vault,
|
||||||
|
isRecursively: boolean = false,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = ""
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
let uploadFile = fileOrFolderPath;
|
||||||
|
if (password !== "") {
|
||||||
|
uploadFile = remoteEncryptedKey;
|
||||||
|
}
|
||||||
|
uploadFile = getWebdavPath(uploadFile, client.vaultName);
|
||||||
|
|
||||||
|
const isFolder = fileOrFolderPath.endsWith("/");
|
||||||
|
|
||||||
|
if (isFolder && isRecursively) {
|
||||||
|
throw Error("upload function doesn't implement recursive function yet!");
|
||||||
|
} else if (isFolder && !isRecursively) {
|
||||||
|
// folder
|
||||||
|
if (password === "") {
|
||||||
|
// if not encrypted, mkdir a remote folder
|
||||||
|
await client.client.createDirectory(uploadFile, {
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
|
const res = await getRemoteMeta(client, uploadFile);
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
// if encrypted, upload a fake file with the encrypted file name
|
||||||
|
await client.client.putFileContents(uploadFile, "", {
|
||||||
|
overwrite: true,
|
||||||
|
onUploadProgress: (progress) => {
|
||||||
|
// log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getRemoteMeta(client, uploadFile);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// file
|
||||||
|
// we ignore isRecursively parameter here
|
||||||
|
const localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
||||||
|
let remoteContent = localContent;
|
||||||
|
if (password !== "") {
|
||||||
|
remoteContent = await encryptArrayBuffer(localContent, password);
|
||||||
|
}
|
||||||
|
// we need to create folders before uploading
|
||||||
|
const dir = getPathFolder(uploadFile);
|
||||||
|
if (dir !== "/" && dir !== "") {
|
||||||
|
await client.client.createDirectory(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
await client.client.putFileContents(uploadFile, remoteContent, {
|
||||||
|
overwrite: true,
|
||||||
|
onUploadProgress: (progress) => {
|
||||||
|
log.info(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getRemoteMeta(client, uploadFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listFromRemote = async (
|
||||||
|
client: WrappedWebdavClient,
|
||||||
|
prefix?: string
|
||||||
|
) => {
|
||||||
|
if (prefix !== undefined) {
|
||||||
|
throw Error("prefix not supported");
|
||||||
|
}
|
||||||
|
await client.init();
|
||||||
|
const contents = (await client.client.getDirectoryContents(
|
||||||
|
`/${client.vaultName}`,
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
details: false /* no need for verbose details here */,
|
||||||
|
glob: "/**" /* avoid dot files by using glob */,
|
||||||
|
}
|
||||||
|
)) as FileStat[];
|
||||||
|
return {
|
||||||
|
Contents: contents.map((x) =>
|
||||||
|
fromWebdavItemToRemoteItem(x, client.vaultName)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadFromRemoteRaw = async (
|
||||||
|
client: WrappedWebdavClient,
|
||||||
|
fileOrFolderPath: string
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
const buff = (await client.client.getFileContents(
|
||||||
|
getWebdavPath(fileOrFolderPath, client.vaultName)
|
||||||
|
)) as BufferLike;
|
||||||
|
if (buff instanceof ArrayBuffer) {
|
||||||
|
return buff;
|
||||||
|
} else if (buff instanceof Buffer) {
|
||||||
|
return bufferToArrayBuffer(buff);
|
||||||
|
}
|
||||||
|
throw Error(`unexpected file content result with type ${typeof buff}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadFromRemote = async (
|
||||||
|
client: WrappedWebdavClient,
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
vault: Vault,
|
||||||
|
mtime: number,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = ""
|
||||||
|
) => {
|
||||||
|
await client.init();
|
||||||
|
|
||||||
|
const isFolder = fileOrFolderPath.endsWith("/");
|
||||||
|
|
||||||
|
await mkdirpInVault(fileOrFolderPath, vault);
|
||||||
|
|
||||||
|
// the file is always local file
|
||||||
|
// we need to encrypt it
|
||||||
|
|
||||||
|
if (isFolder) {
|
||||||
|
// mkdirp locally is enough
|
||||||
|
// do nothing here
|
||||||
|
} else {
|
||||||
|
let downloadFile = fileOrFolderPath;
|
||||||
|
if (password !== "") {
|
||||||
|
downloadFile = remoteEncryptedKey;
|
||||||
|
}
|
||||||
|
downloadFile = getWebdavPath(downloadFile, client.vaultName);
|
||||||
|
const remoteContent = await downloadFromRemoteRaw(client, downloadFile);
|
||||||
|
let localContent = remoteContent;
|
||||||
|
if (password !== "") {
|
||||||
|
localContent = await decryptArrayBuffer(remoteContent, password);
|
||||||
|
}
|
||||||
|
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||||
|
mtime: mtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteFromRemote = async (
|
||||||
|
client: WrappedWebdavClient,
|
||||||
|
fileOrFolderPath: string,
|
||||||
|
password: string = "",
|
||||||
|
remoteEncryptedKey: string = ""
|
||||||
|
) => {
|
||||||
|
if (fileOrFolderPath === "/") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let remoteFileName = fileOrFolderPath;
|
||||||
|
if (password !== "") {
|
||||||
|
remoteFileName = remoteEncryptedKey;
|
||||||
|
}
|
||||||
|
remoteFileName = getWebdavPath(remoteFileName, client.vaultName);
|
||||||
|
|
||||||
|
await client.init();
|
||||||
|
try {
|
||||||
|
await client.client.deleteFile(remoteFileName);
|
||||||
|
// log.info(`delete ${remoteFileName} succeeded`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("some error while deleting");
|
||||||
|
log.info(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkConnectivity = async (client: WrappedWebdavClient) => {
|
||||||
|
try {
|
||||||
|
await client.init();
|
||||||
|
const results = await getRemoteMeta(client, "/");
|
||||||
|
if (results === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
+988
@@ -0,0 +1,988 @@
|
|||||||
|
import { App, Modal, Notice, PluginSettingTab, Setting } from "obsidian";
|
||||||
|
import type { SUPPORTED_SERVICES_TYPE, WebdavAuthType } from "./baseTypes";
|
||||||
|
import { exportSyncPlansToFiles } from "./debugMode";
|
||||||
|
import { exportQrCodeUri } from "./importExport";
|
||||||
|
import {
|
||||||
|
clearAllSyncMetaMapping,
|
||||||
|
clearAllSyncPlanRecords,
|
||||||
|
destroyDBs,
|
||||||
|
} from "./localdb";
|
||||||
|
import type RemotelySavePlugin from "./main"; // unavoidable
|
||||||
|
import { RemoteClient } from "./remote";
|
||||||
|
import {
|
||||||
|
DEFAULT_DROPBOX_CONFIG,
|
||||||
|
getAuthUrlAndVerifier as getAuthUrlAndVerifierDropbox,
|
||||||
|
sendAuthReq as sendAuthReqDropbox,
|
||||||
|
setConfigBySuccessfullAuthInplace,
|
||||||
|
} from "./remoteForDropbox";
|
||||||
|
import {
|
||||||
|
DEFAULT_ONEDRIVE_CONFIG,
|
||||||
|
getAuthUrlAndVerifier as getAuthUrlAndVerifierOnedrive,
|
||||||
|
} from "./remoteForOnedrive";
|
||||||
|
|
||||||
|
import * as origLog from "loglevel";
|
||||||
|
const log = origLog.getLogger("rs-default");
|
||||||
|
|
||||||
|
class PasswordModal extends Modal {
|
||||||
|
plugin: RemotelySavePlugin;
|
||||||
|
newPassword: string;
|
||||||
|
constructor(app: App, plugin: RemotelySavePlugin, newPassword: string) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.newPassword = newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
// contentEl.setText("Add Or change password.");
|
||||||
|
contentEl.createEl("h2", { text: "Hold on and PLEASE READ ON..." });
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "If the field is not empty, files would be encrypted locally before being uploaded.",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "If the field is empty, then files would be uploaded without encryption.",
|
||||||
|
});
|
||||||
|
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Attention 1/5: The vault name is NOT encrypted. The plugin creates a folder with the vault name on some remote services.",
|
||||||
|
cls: "password-disclaimer",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Attention 2/5: The password itself is stored in PLAIN TEXT LOCALLY.",
|
||||||
|
cls: "password-disclaimer",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Attention 3/5: Some metadata are not encrypted or can be easily guessed. (File sizes are closed to their unencrypted ones, and directory path may be stored as 0-byte-size object.)",
|
||||||
|
cls: "password-disclaimer",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Attention 4/5: You should make sure the remote store IS EMPTY, or REMOTE FILES WERE ENCRYPTED BY THAT NEW PASSWORD, to avoid conflictions.",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Attention 5/5: The longer the password, the better.",
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton((button) => {
|
||||||
|
button.setButtonText("The Second Confirm to change password.");
|
||||||
|
button.onClick(async () => {
|
||||||
|
this.plugin.settings.password = this.newPassword;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
new Notice("New password saved!");
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
button.setClass("password-second-confirm");
|
||||||
|
})
|
||||||
|
.addButton((button) => {
|
||||||
|
button.setButtonText("Go Back");
|
||||||
|
button.onClick(() => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DropboxAuthModal extends Modal {
|
||||||
|
readonly plugin: RemotelySavePlugin;
|
||||||
|
readonly authDiv: HTMLDivElement;
|
||||||
|
readonly revokeAuthDiv: HTMLDivElement;
|
||||||
|
readonly revokeAuthSetting: Setting;
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
plugin: RemotelySavePlugin,
|
||||||
|
authDiv: HTMLDivElement,
|
||||||
|
revokeAuthDiv: HTMLDivElement,
|
||||||
|
revokeAuthSetting: Setting
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.authDiv = authDiv;
|
||||||
|
this.revokeAuthDiv = revokeAuthDiv;
|
||||||
|
this.revokeAuthSetting = revokeAuthSetting;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
|
||||||
|
const { authUrl, verifier } = await getAuthUrlAndVerifierDropbox(
|
||||||
|
this.plugin.settings.dropbox.clientID
|
||||||
|
);
|
||||||
|
this.plugin.oauth2Info.verifier = verifier;
|
||||||
|
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Visit the address in a browser, and follow the steps.",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Finally you should be redirected to Obsidian.",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p").createEl("a", {
|
||||||
|
href: authUrl,
|
||||||
|
text: authUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnedriveAuthModal extends Modal {
|
||||||
|
readonly plugin: RemotelySavePlugin;
|
||||||
|
readonly authDiv: HTMLDivElement;
|
||||||
|
readonly revokeAuthDiv: HTMLDivElement;
|
||||||
|
readonly revokeAuthSetting: Setting;
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
plugin: RemotelySavePlugin,
|
||||||
|
authDiv: HTMLDivElement,
|
||||||
|
revokeAuthDiv: HTMLDivElement,
|
||||||
|
revokeAuthSetting: Setting
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.authDiv = authDiv;
|
||||||
|
this.revokeAuthDiv = revokeAuthDiv;
|
||||||
|
this.revokeAuthSetting = revokeAuthSetting;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
|
||||||
|
const { authUrl, verifier } = await getAuthUrlAndVerifierOnedrive(
|
||||||
|
this.plugin.settings.onedrive.clientID,
|
||||||
|
this.plugin.settings.onedrive.authority
|
||||||
|
);
|
||||||
|
this.plugin.oauth2Info.verifier = verifier;
|
||||||
|
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Currently only OneDrive for personal is supported. OneDrive for Business is NOT supported (yet).",
|
||||||
|
});
|
||||||
|
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Visit the address in a browser, and follow the steps.",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Finally you should be redirected to Obsidian.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const div2 = contentEl.createDiv();
|
||||||
|
div2.createEl(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
text: "Click to copy the auth url",
|
||||||
|
},
|
||||||
|
(el) => {
|
||||||
|
el.onclick = async () => {
|
||||||
|
await navigator.clipboard.writeText(authUrl);
|
||||||
|
new Notice("the auth url copied to clipboard!");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
contentEl.createEl("p").createEl("a", {
|
||||||
|
href: authUrl,
|
||||||
|
text: authUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OnedriveRevokeAuthModal extends Modal {
|
||||||
|
readonly plugin: RemotelySavePlugin;
|
||||||
|
readonly authDiv: HTMLDivElement;
|
||||||
|
readonly revokeAuthDiv: HTMLDivElement;
|
||||||
|
constructor(
|
||||||
|
app: App,
|
||||||
|
plugin: RemotelySavePlugin,
|
||||||
|
authDiv: HTMLDivElement,
|
||||||
|
revokeAuthDiv: HTMLDivElement
|
||||||
|
) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.authDiv = authDiv;
|
||||||
|
this.revokeAuthDiv = revokeAuthDiv;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: 'Step 1: Go to the following address, click the "Edit" button for the plugin, then click "Remove these permissions" button on the page.',
|
||||||
|
});
|
||||||
|
const consentUrl = "https://microsoft.com/consent";
|
||||||
|
contentEl.createEl("p").createEl("a", {
|
||||||
|
href: consentUrl,
|
||||||
|
text: consentUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Step 2: Click the button below, to clean the locally-saved login credentials.",
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.setName("Clean Locally-Saved Login Credentials")
|
||||||
|
.setDesc("You need to click the button.")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Clean");
|
||||||
|
button.onClick(async () => {
|
||||||
|
try {
|
||||||
|
this.plugin.settings.onedrive = JSON.parse(
|
||||||
|
JSON.stringify(DEFAULT_ONEDRIVE_CONFIG)
|
||||||
|
);
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
this.authDiv.toggleClass(
|
||||||
|
"onedrive-auth-button-hide",
|
||||||
|
this.plugin.settings.onedrive.username !== ""
|
||||||
|
);
|
||||||
|
this.revokeAuthDiv.toggleClass(
|
||||||
|
"onedrive-revoke-auth-button-hide",
|
||||||
|
this.plugin.settings.onedrive.username === ""
|
||||||
|
);
|
||||||
|
new Notice("Cleaned!");
|
||||||
|
this.close();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
new Notice("Something goes wrong while revoking");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExportSettingsQrCodeModal extends Modal {
|
||||||
|
plugin: RemotelySavePlugin;
|
||||||
|
constructor(app: App, plugin: RemotelySavePlugin) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onOpen() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
|
||||||
|
const { rawUri, imgUri } = await exportQrCodeUri(
|
||||||
|
this.plugin.settings,
|
||||||
|
this.app.vault.getName(),
|
||||||
|
this.plugin.manifest.version
|
||||||
|
);
|
||||||
|
|
||||||
|
const div1 = contentEl.createDiv();
|
||||||
|
div1.createEl("p", {
|
||||||
|
text: "This exports not-oauth2 settings. (It means that Dropbox, OneDrive info are NOT exported.)",
|
||||||
|
});
|
||||||
|
div1.createEl("p", {
|
||||||
|
text: "You can use another device to scan this qrcode.",
|
||||||
|
});
|
||||||
|
div1.createEl("p", {
|
||||||
|
text: "Or, you can click the button to copy the special url.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const div2 = contentEl.createDiv();
|
||||||
|
div2.createEl(
|
||||||
|
"button",
|
||||||
|
{
|
||||||
|
text: "Click to copy the special URI",
|
||||||
|
},
|
||||||
|
(el) => {
|
||||||
|
el.onclick = async () => {
|
||||||
|
await navigator.clipboard.writeText(rawUri);
|
||||||
|
new Notice("special uri copied to clipboard!");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const div3 = contentEl.createDiv();
|
||||||
|
div3.createEl(
|
||||||
|
"img",
|
||||||
|
{
|
||||||
|
cls: "qrcode-img",
|
||||||
|
},
|
||||||
|
async (el) => {
|
||||||
|
el.src = imgUri;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemotelySaveSettingTab extends PluginSettingTab {
|
||||||
|
plugin: RemotelySavePlugin;
|
||||||
|
|
||||||
|
constructor(app: App, plugin: RemotelySavePlugin) {
|
||||||
|
super(app, plugin);
|
||||||
|
this.plugin = plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
display(): void {
|
||||||
|
let { containerEl } = this;
|
||||||
|
|
||||||
|
containerEl.empty();
|
||||||
|
|
||||||
|
containerEl.createEl("h1", { text: "Remotely Save" });
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
// below for general
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
|
||||||
|
const generalDiv = containerEl.createEl("div");
|
||||||
|
generalDiv.createEl("h2", { text: "General" });
|
||||||
|
|
||||||
|
const passwordDiv = generalDiv.createEl("div");
|
||||||
|
let newPassword = `${this.plugin.settings.password}`;
|
||||||
|
new Setting(passwordDiv)
|
||||||
|
.setName("encryption password")
|
||||||
|
.setDesc(
|
||||||
|
'Password for E2E encryption. Empty for no password. You need to click "Confirm".'
|
||||||
|
)
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("")
|
||||||
|
.setValue(`${this.plugin.settings.password}`)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
newPassword = value.trim();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Confirm");
|
||||||
|
button.onClick(async () => {
|
||||||
|
new PasswordModal(this.app, this.plugin, newPassword).open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
// below for general chooser (part 1/2)
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// we need to create the div in advance of any other service divs
|
||||||
|
const serviceChooserDiv = generalDiv.createEl("div");
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
// below for s3
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
|
||||||
|
const s3Div = containerEl.createEl("div", { cls: "s3-hide" });
|
||||||
|
s3Div.toggleClass("s3-hide", this.plugin.settings.serviceType !== "s3");
|
||||||
|
s3Div.createEl("h2", { text: "Remote For S3 or compatible" });
|
||||||
|
|
||||||
|
s3Div.createEl("p", {
|
||||||
|
text: "Disclaimer: This plugin is NOT an official Amazon product.",
|
||||||
|
cls: "s3-disclaimer",
|
||||||
|
});
|
||||||
|
|
||||||
|
s3Div.createEl("p", {
|
||||||
|
text: "Disclaimer: The information is stored in locally. Other malicious/harmful/faulty plugins could read the info. If you see any unintentional access to your bucket, please immediately delete the access key on your AWS (or other S3-service provider) settings.",
|
||||||
|
cls: "s3-disclaimer",
|
||||||
|
});
|
||||||
|
|
||||||
|
s3Div.createEl("p", {
|
||||||
|
text: "You need to configure CORS to allow requests from origin app://obsidian.md and capacitor://localhost and http://localhost",
|
||||||
|
});
|
||||||
|
|
||||||
|
s3Div.createEl("p", {
|
||||||
|
text: "Some Amazon S3 official docs for references:",
|
||||||
|
});
|
||||||
|
|
||||||
|
const s3LinksUl = s3Div.createEl("div").createEl("ul");
|
||||||
|
|
||||||
|
s3LinksUl.createEl("li").createEl("a", {
|
||||||
|
href: "https://docs.aws.amazon.com/general/latest/gr/s3.html",
|
||||||
|
text: "Endpoint and region info",
|
||||||
|
});
|
||||||
|
|
||||||
|
s3LinksUl.createEl("li").createEl("a", {
|
||||||
|
href: "https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html",
|
||||||
|
text: "Access key ID and Secret access key info",
|
||||||
|
});
|
||||||
|
|
||||||
|
s3LinksUl.createEl("li").createEl("a", {
|
||||||
|
href: "https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html",
|
||||||
|
text: "Configuring CORS",
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(s3Div)
|
||||||
|
.setName("s3Endpoint")
|
||||||
|
.setDesc("s3Endpoint")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("")
|
||||||
|
.setValue(this.plugin.settings.s3.s3Endpoint)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.s3.s3Endpoint = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(s3Div)
|
||||||
|
.setName("s3Region")
|
||||||
|
.setDesc("s3Region")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("")
|
||||||
|
.setValue(`${this.plugin.settings.s3.s3Region}`)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.s3.s3Region = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(s3Div)
|
||||||
|
.setName("s3AccessKeyID")
|
||||||
|
.setDesc("s3AccessKeyID")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("")
|
||||||
|
.setValue(`${this.plugin.settings.s3.s3AccessKeyID}`)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.s3.s3AccessKeyID = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(s3Div)
|
||||||
|
.setName("s3SecretAccessKey")
|
||||||
|
.setDesc("s3SecretAccessKey")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("")
|
||||||
|
.setValue(`${this.plugin.settings.s3.s3SecretAccessKey}`)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.s3.s3SecretAccessKey = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(s3Div)
|
||||||
|
.setName("s3BucketName")
|
||||||
|
.setDesc("s3BucketName")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("")
|
||||||
|
.setValue(`${this.plugin.settings.s3.s3BucketName}`)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.s3.s3BucketName = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(s3Div)
|
||||||
|
.setName("check connectivity")
|
||||||
|
.setDesc("check connectivity")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Check");
|
||||||
|
button.onClick(async () => {
|
||||||
|
new Notice("Checking...");
|
||||||
|
const client = new RemoteClient("s3", this.plugin.settings.s3);
|
||||||
|
const res = await client.checkConnectivity();
|
||||||
|
if (res) {
|
||||||
|
new Notice("Great! The bucket can be accessed.");
|
||||||
|
} else {
|
||||||
|
new Notice("The S3 bucket cannot be reached.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
// below for dropbpx
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
|
||||||
|
const dropboxDiv = containerEl.createEl("div", { cls: "dropbox-hide" });
|
||||||
|
dropboxDiv.toggleClass(
|
||||||
|
"dropbox-hide",
|
||||||
|
this.plugin.settings.serviceType !== "dropbox"
|
||||||
|
);
|
||||||
|
dropboxDiv.createEl("h2", { text: "Remote For Dropbox" });
|
||||||
|
dropboxDiv.createEl("p", {
|
||||||
|
text: "Disclaimer: This app is NOT an official Dropbox product.",
|
||||||
|
cls: "dropbox-disclaimer",
|
||||||
|
});
|
||||||
|
dropboxDiv.createEl("p", {
|
||||||
|
text: "Disclaimer: The information is stored in locally. Other malicious/harmful/faulty plugins could read the info. If you see any unintentional access to your Dropbox, please immediately disconnect this app on https://www.dropbox.com/account/connected_apps .",
|
||||||
|
cls: "dropbox-disclaimer",
|
||||||
|
});
|
||||||
|
dropboxDiv.createEl("p", {
|
||||||
|
text: `We will create and sync inside the folder /Apps/${
|
||||||
|
this.plugin.manifest.id
|
||||||
|
}/${this.app.vault.getName()} on your Dropbox.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropboxSelectAuthDiv = dropboxDiv.createDiv();
|
||||||
|
const dropboxAuthDiv = dropboxSelectAuthDiv.createDiv({
|
||||||
|
cls: "dropbox-auth-button-hide",
|
||||||
|
});
|
||||||
|
const dropboxRevokeAuthDiv = dropboxSelectAuthDiv.createDiv({
|
||||||
|
cls: "dropbox-revoke-auth-button-hide",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropboxRevokeAuthSetting = new Setting(dropboxRevokeAuthDiv)
|
||||||
|
.setName("Revoke Auth")
|
||||||
|
.setDesc(
|
||||||
|
`You've connected as user ${this.plugin.settings.dropbox.username}. If you want to disconnect, click this button`
|
||||||
|
)
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Revoke Auth");
|
||||||
|
button.onClick(async () => {
|
||||||
|
try {
|
||||||
|
const self = this;
|
||||||
|
const client = new RemoteClient(
|
||||||
|
"dropbox",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
this.plugin.settings.dropbox,
|
||||||
|
undefined,
|
||||||
|
this.app.vault.getName(),
|
||||||
|
() => self.plugin.saveSettings()
|
||||||
|
);
|
||||||
|
await client.revokeAuth();
|
||||||
|
this.plugin.settings.dropbox = JSON.parse(
|
||||||
|
JSON.stringify(DEFAULT_DROPBOX_CONFIG)
|
||||||
|
);
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
dropboxAuthDiv.toggleClass(
|
||||||
|
"dropbox-auth-button-hide",
|
||||||
|
this.plugin.settings.dropbox.username !== ""
|
||||||
|
);
|
||||||
|
dropboxRevokeAuthDiv.toggleClass(
|
||||||
|
"dropbox-revoke-auth-button-hide",
|
||||||
|
this.plugin.settings.dropbox.username === ""
|
||||||
|
);
|
||||||
|
new Notice("Revoked!");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
new Notice("Something goes wrong while revoking");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(dropboxAuthDiv)
|
||||||
|
.setName("Auth")
|
||||||
|
.setDesc("Auth")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Auth");
|
||||||
|
button.onClick(async () => {
|
||||||
|
const modal = new DropboxAuthModal(
|
||||||
|
this.app,
|
||||||
|
this.plugin,
|
||||||
|
dropboxAuthDiv,
|
||||||
|
dropboxRevokeAuthDiv,
|
||||||
|
dropboxRevokeAuthSetting
|
||||||
|
);
|
||||||
|
this.plugin.oauth2Info.helperModal = modal;
|
||||||
|
this.plugin.oauth2Info.authDiv = dropboxAuthDiv;
|
||||||
|
this.plugin.oauth2Info.revokeDiv = dropboxRevokeAuthDiv;
|
||||||
|
this.plugin.oauth2Info.revokeAuthSetting = dropboxRevokeAuthSetting;
|
||||||
|
modal.open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
dropboxAuthDiv.toggleClass(
|
||||||
|
"dropbox-auth-button-hide",
|
||||||
|
this.plugin.settings.dropbox.username !== ""
|
||||||
|
);
|
||||||
|
dropboxRevokeAuthDiv.toggleClass(
|
||||||
|
"dropbox-revoke-auth-button-hide",
|
||||||
|
this.plugin.settings.dropbox.username === ""
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(dropboxDiv)
|
||||||
|
.setName("check connectivity")
|
||||||
|
.setDesc("check connectivity")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Check");
|
||||||
|
button.onClick(async () => {
|
||||||
|
new Notice("Checking...");
|
||||||
|
const self = this;
|
||||||
|
const client = new RemoteClient(
|
||||||
|
"dropbox",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
this.plugin.settings.dropbox,
|
||||||
|
undefined,
|
||||||
|
this.app.vault.getName(),
|
||||||
|
() => self.plugin.saveSettings()
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await client.checkConnectivity();
|
||||||
|
if (res) {
|
||||||
|
new Notice("Great! We can connect to Dropbox!");
|
||||||
|
} else {
|
||||||
|
new Notice("We cannot connect to Dropbox.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
// below for onedrive
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
|
||||||
|
const onedriveDiv = containerEl.createEl("div", { cls: "onedrive-hide" });
|
||||||
|
onedriveDiv.toggleClass(
|
||||||
|
"onedrive-hide",
|
||||||
|
this.plugin.settings.serviceType !== "onedrive"
|
||||||
|
);
|
||||||
|
onedriveDiv.createEl("h2", { text: "Remote For Onedrive (for personal)" });
|
||||||
|
onedriveDiv.createEl("p", {
|
||||||
|
text: "Disclaimer: This app is NOT an official Microsoft / Onedrive product.",
|
||||||
|
cls: "onedrive-disclaimer",
|
||||||
|
});
|
||||||
|
onedriveDiv.createEl("p", {
|
||||||
|
text: "Disclaimer: The information is stored locally. Other malicious/harmful/faulty plugins could read the info. If you see any unintentional access to your Onedrive, please immediately disconnect this app on https://microsoft.com/consent .",
|
||||||
|
cls: "onedrive-disclaimer",
|
||||||
|
});
|
||||||
|
onedriveDiv.createEl("p", {
|
||||||
|
text: `We will create and sync inside the folder /Apps/${
|
||||||
|
this.plugin.manifest.id
|
||||||
|
}/${this.app.vault.getName()} on your Onedrive.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
onedriveDiv.createEl("p", {
|
||||||
|
text: "Currently only OneDrive for personal is supported. OneDrive for Business is NOT supported (yet).",
|
||||||
|
});
|
||||||
|
|
||||||
|
const onedriveSelectAuthDiv = onedriveDiv.createDiv();
|
||||||
|
const onedriveAuthDiv = onedriveSelectAuthDiv.createDiv({
|
||||||
|
cls: "onedrive-auth-button-hide",
|
||||||
|
});
|
||||||
|
const onedriveRevokeAuthDiv = onedriveSelectAuthDiv.createDiv({
|
||||||
|
cls: "onedrive-revoke-auth-button-hide",
|
||||||
|
});
|
||||||
|
|
||||||
|
const onedriveRevokeAuthSetting = new Setting(onedriveRevokeAuthDiv)
|
||||||
|
.setName("Revoke Auth")
|
||||||
|
.setDesc(
|
||||||
|
`You've connected as user ${this.plugin.settings.onedrive.username}. If you want to disconnect, click this button`
|
||||||
|
)
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Revoke Auth");
|
||||||
|
button.onClick(async () => {
|
||||||
|
new OnedriveRevokeAuthModal(
|
||||||
|
this.app,
|
||||||
|
this.plugin,
|
||||||
|
onedriveAuthDiv,
|
||||||
|
onedriveRevokeAuthDiv
|
||||||
|
).open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(onedriveAuthDiv)
|
||||||
|
.setName("Auth")
|
||||||
|
.setDesc("Auth")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Auth");
|
||||||
|
button.onClick(async () => {
|
||||||
|
const modal = new OnedriveAuthModal(
|
||||||
|
this.app,
|
||||||
|
this.plugin,
|
||||||
|
onedriveAuthDiv,
|
||||||
|
onedriveRevokeAuthDiv,
|
||||||
|
onedriveRevokeAuthSetting
|
||||||
|
);
|
||||||
|
this.plugin.oauth2Info.helperModal = modal;
|
||||||
|
this.plugin.oauth2Info.authDiv = onedriveAuthDiv;
|
||||||
|
this.plugin.oauth2Info.revokeDiv = onedriveRevokeAuthDiv;
|
||||||
|
this.plugin.oauth2Info.revokeAuthSetting = onedriveRevokeAuthSetting;
|
||||||
|
modal.open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onedriveAuthDiv.toggleClass(
|
||||||
|
"onedrive-auth-button-hide",
|
||||||
|
this.plugin.settings.onedrive.username !== ""
|
||||||
|
);
|
||||||
|
onedriveRevokeAuthDiv.toggleClass(
|
||||||
|
"onedrive-revoke-auth-button-hide",
|
||||||
|
this.plugin.settings.onedrive.username === ""
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(onedriveDiv)
|
||||||
|
.setName("check connectivity")
|
||||||
|
.setDesc("check connectivity")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Check");
|
||||||
|
button.onClick(async () => {
|
||||||
|
new Notice("Checking...");
|
||||||
|
const self = this;
|
||||||
|
const client = new RemoteClient(
|
||||||
|
"onedrive",
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
this.plugin.settings.onedrive,
|
||||||
|
this.app.vault.getName(),
|
||||||
|
() => self.plugin.saveSettings()
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await client.checkConnectivity();
|
||||||
|
if (res) {
|
||||||
|
new Notice("Great! We can connect to Onedrive!");
|
||||||
|
} else {
|
||||||
|
new Notice("We cannot connect to Onedrive.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
// below for webdav
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
|
||||||
|
const webdavDiv = containerEl.createEl("div", { cls: "webdav-hide" });
|
||||||
|
webdavDiv.toggleClass(
|
||||||
|
"webdav-hide",
|
||||||
|
this.plugin.settings.serviceType !== "webdav"
|
||||||
|
);
|
||||||
|
|
||||||
|
webdavDiv.createEl("h2", { text: "Remote For Webdav" });
|
||||||
|
|
||||||
|
webdavDiv.createEl("p", {
|
||||||
|
text: "Disclaimer: The information is stored in locally. Other malicious/harmful/faulty plugins may read the info. If you see any unintentional access to your webdav server, please immediately change the username and password.",
|
||||||
|
cls: "webdav-disclaimer",
|
||||||
|
});
|
||||||
|
|
||||||
|
webdavDiv.createEl("p", {
|
||||||
|
text: "You need to configure CORS to allow requests from origin app://obsidian.md and capacitor://localhost and http://localhost",
|
||||||
|
});
|
||||||
|
|
||||||
|
webdavDiv.createEl("p", {
|
||||||
|
text: `We will create and sync inside the folder /${this.app.vault.getName()} on your server.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(webdavDiv)
|
||||||
|
.setName("server address")
|
||||||
|
.setDesc("server address")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("")
|
||||||
|
.setValue(this.plugin.settings.webdav.address)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.webdav.address = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(webdavDiv)
|
||||||
|
.setName("server username")
|
||||||
|
.setDesc("server username")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("")
|
||||||
|
.setValue(this.plugin.settings.webdav.username)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.webdav.username = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(webdavDiv)
|
||||||
|
.setName("server password")
|
||||||
|
.setDesc("server password")
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("")
|
||||||
|
.setValue(this.plugin.settings.webdav.password)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
this.plugin.settings.webdav.password = value.trim();
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
new Setting(webdavDiv)
|
||||||
|
.setName("server auth type")
|
||||||
|
.setDesc("If no password, this option would be ignored.")
|
||||||
|
.addDropdown((dropdown) => {
|
||||||
|
dropdown.addOption("basic", "basic");
|
||||||
|
// dropdown.addOption("digest", "digest");
|
||||||
|
|
||||||
|
dropdown
|
||||||
|
.setValue(this.plugin.settings.webdav.authType)
|
||||||
|
.onChange(async (val: WebdavAuthType) => {
|
||||||
|
this.plugin.settings.webdav.authType = val;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(webdavDiv)
|
||||||
|
.setName("check connectivity")
|
||||||
|
.setDesc("check connectivity")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Check");
|
||||||
|
button.onClick(async () => {
|
||||||
|
new Notice("Checking...");
|
||||||
|
const client = new RemoteClient(
|
||||||
|
"webdav",
|
||||||
|
undefined,
|
||||||
|
this.plugin.settings.webdav,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
this.app.vault.getName()
|
||||||
|
);
|
||||||
|
const res = await client.checkConnectivity();
|
||||||
|
if (res) {
|
||||||
|
new Notice("Great! The webdav server can be accessed.");
|
||||||
|
} else {
|
||||||
|
new Notice(
|
||||||
|
"The webdav server cannot be reached (possible to be any of address/username/password/authtype/CORS errors)."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
// below for general chooser (part 2/2)
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// we need to create chooser
|
||||||
|
// after all service-div-s being created
|
||||||
|
new Setting(serviceChooserDiv)
|
||||||
|
.setName("Choose service")
|
||||||
|
.setDesc("Choose a service.")
|
||||||
|
.addDropdown(async (dropdown) => {
|
||||||
|
dropdown.addOption("s3", "S3 or compatible");
|
||||||
|
dropdown.addOption("dropbox", "Dropbox");
|
||||||
|
dropdown.addOption("webdav", "Webdav (beta)");
|
||||||
|
dropdown.addOption("onedrive", "OneDrive for personal (alpha)");
|
||||||
|
dropdown
|
||||||
|
.setValue(this.plugin.settings.serviceType)
|
||||||
|
.onChange(async (val: SUPPORTED_SERVICES_TYPE) => {
|
||||||
|
this.plugin.settings.serviceType = val;
|
||||||
|
s3Div.toggleClass(
|
||||||
|
"s3-hide",
|
||||||
|
this.plugin.settings.serviceType !== "s3"
|
||||||
|
);
|
||||||
|
dropboxDiv.toggleClass(
|
||||||
|
"dropbox-hide",
|
||||||
|
this.plugin.settings.serviceType !== "dropbox"
|
||||||
|
);
|
||||||
|
onedriveDiv.toggleClass(
|
||||||
|
"onedrive-hide",
|
||||||
|
this.plugin.settings.serviceType !== "onedrive"
|
||||||
|
);
|
||||||
|
webdavDiv.toggleClass(
|
||||||
|
"webdav-hide",
|
||||||
|
this.plugin.settings.serviceType !== "webdav"
|
||||||
|
);
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
// below for import and export functions
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// import and export
|
||||||
|
const importExportDiv = containerEl.createEl("div");
|
||||||
|
importExportDiv.createEl("h2", {
|
||||||
|
text: "Import and Export Partial Settings",
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(importExportDiv)
|
||||||
|
.setName("export")
|
||||||
|
.setDesc("Export not-oauth2 settings by generating a qrcode.")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Get QR Code");
|
||||||
|
button.onClick(async () => {
|
||||||
|
new ExportSettingsQrCodeModal(this.app, this.plugin).open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(importExportDiv)
|
||||||
|
.setName("import")
|
||||||
|
.setDesc(
|
||||||
|
"You should open a camera or scan-qrcode app, to manually scan the QR code."
|
||||||
|
);
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
// below for debug
|
||||||
|
//////////////////////////////////////////////////
|
||||||
|
|
||||||
|
const debugDiv = containerEl.createEl("div");
|
||||||
|
debugDiv.createEl("h2", { text: "Debug" });
|
||||||
|
|
||||||
|
const setConsoleLogLevelDiv = debugDiv.createDiv("div");
|
||||||
|
new Setting(setConsoleLogLevelDiv)
|
||||||
|
.setName("alter console log level")
|
||||||
|
.setDesc(
|
||||||
|
'By default the log level is "info". You can change to "debug" to get verbose infomation in console.'
|
||||||
|
)
|
||||||
|
.addDropdown(async (dropdown) => {
|
||||||
|
dropdown.addOption("info", "info");
|
||||||
|
dropdown.addOption("debug", "debug");
|
||||||
|
dropdown
|
||||||
|
.setValue(this.plugin.settings.currLogLevel)
|
||||||
|
.onChange(async (val: string) => {
|
||||||
|
this.plugin.settings.currLogLevel = val;
|
||||||
|
log.setLevel(val as any);
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
log.info(`the log level is changed to ${val}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const syncPlanDiv = debugDiv.createEl("div");
|
||||||
|
new Setting(syncPlanDiv)
|
||||||
|
.setName("export sync plans")
|
||||||
|
.setDesc(
|
||||||
|
"Sync plans are created every time after you trigger sync and before the actual sync. Useful to know what would actually happen in those sync. Click the button to export sync plans"
|
||||||
|
)
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Export");
|
||||||
|
button.onClick(async () => {
|
||||||
|
await exportSyncPlansToFiles(this.plugin.db, this.app.vault);
|
||||||
|
new Notice("sync plans history exported");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
new Setting(syncPlanDiv)
|
||||||
|
.setName("delete sync plans history in db")
|
||||||
|
.setDesc("delete sync plans history in db")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Delete History");
|
||||||
|
button.onClick(async () => {
|
||||||
|
await clearAllSyncPlanRecords(this.plugin.db);
|
||||||
|
new Notice("sync plans history (in db) deleted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncMappingDiv = debugDiv.createEl("div");
|
||||||
|
new Setting(syncMappingDiv)
|
||||||
|
.setName("delete sync mappings history in db")
|
||||||
|
.setDesc(
|
||||||
|
"Sync mappings history stores the actual LOCAL last modified time of the REMOTE objects. Clearing it may cause unnecessary data exchanges in next-time sync. Click the button to delete sync mappings history in db"
|
||||||
|
)
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Delete Sync Mappings");
|
||||||
|
button.onClick(async () => {
|
||||||
|
await clearAllSyncMetaMapping(this.plugin.db);
|
||||||
|
new Notice("sync mappings history (in local db) deleted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbsResetDiv = debugDiv.createEl("div");
|
||||||
|
new Setting(dbsResetDiv)
|
||||||
|
.setName("reset local internal cache/databases")
|
||||||
|
.setDesc(
|
||||||
|
"Reset local internal caches/databases (for debugging purposes). You would want to reload the plugin after resetting this. This option will not empty the {s3, password...} settings."
|
||||||
|
)
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Reset");
|
||||||
|
button.onClick(async () => {
|
||||||
|
await destroyDBs();
|
||||||
|
new Notice(
|
||||||
|
"Local internal cache/databases deleted. Please manually reload the plugin."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+298
-79
@@ -1,23 +1,33 @@
|
|||||||
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
import { TAbstractFile, TFile, TFolder, Vault } from "obsidian";
|
||||||
|
import type { RemoteItem, SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
||||||
import { S3Client } from "@aws-sdk/client-s3";
|
import {
|
||||||
import * as lf from "lovefield-ts/dist/es6/lf.js";
|
decryptBase32ToString,
|
||||||
|
decryptBase64urlToString,
|
||||||
|
encryptStringToBase64url,
|
||||||
|
MAGIC_ENCRYPTED_PREFIX_BASE32,
|
||||||
|
MAGIC_ENCRYPTED_PREFIX_BASE64URL,
|
||||||
|
} from "./encrypt";
|
||||||
|
import type { FileFolderHistoryRecord, InternalDBs } from "./localdb";
|
||||||
import {
|
import {
|
||||||
clearDeleteRenameHistoryOfKey,
|
clearDeleteRenameHistoryOfKey,
|
||||||
FileFolderHistoryRecord,
|
getSyncMetaMappingByRemoteKey,
|
||||||
upsertSyncMetaMappingDataS3,
|
upsertSyncMetaMappingData,
|
||||||
getSyncMetaMappingByRemoteKeyS3,
|
|
||||||
} from "./localdb";
|
} from "./localdb";
|
||||||
import {
|
import { isHiddenPath, isVaildText, mkdirpInVault } from "./misc";
|
||||||
S3Config,
|
import { RemoteClient } from "./remote";
|
||||||
S3ObjectType,
|
|
||||||
uploadToRemote,
|
import * as origLog from "loglevel";
|
||||||
deleteFromRemote,
|
const log = origLog.getLogger("rs-default");
|
||||||
downloadFromRemote,
|
|
||||||
} from "./s3";
|
export type SyncStatusType =
|
||||||
import { mkdirpInVault } from "./misc";
|
| "idle"
|
||||||
import { decryptBase32ToString, encryptStringToBase32 } from "./encrypt";
|
| "preparing"
|
||||||
|
| "getting_remote_meta"
|
||||||
|
| "getting_local_meta"
|
||||||
|
| "checking_password"
|
||||||
|
| "generating_plan"
|
||||||
|
| "syncing"
|
||||||
|
| "finish";
|
||||||
|
|
||||||
type DecisionType =
|
type DecisionType =
|
||||||
| "undecided"
|
| "undecided"
|
||||||
@@ -31,8 +41,6 @@ type DecisionType =
|
|||||||
| "mkdirplocal"
|
| "mkdirplocal"
|
||||||
| "skip";
|
| "skip";
|
||||||
|
|
||||||
export type SyncStatusType = "idle" | "preparing" | "syncing";
|
|
||||||
|
|
||||||
interface FileOrFolderMixedState {
|
interface FileOrFolderMixedState {
|
||||||
key: string;
|
key: string;
|
||||||
exist_local?: boolean;
|
exist_local?: boolean;
|
||||||
@@ -48,48 +56,170 @@ interface FileOrFolderMixedState {
|
|||||||
remote_encrypted_key?: string;
|
remote_encrypted_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ensembleMixedStates = async (
|
export interface SyncPlanType {
|
||||||
remote: S3ObjectType[],
|
ts: number;
|
||||||
|
remoteType: SUPPORTED_SERVICES_TYPE;
|
||||||
|
mixedStates: Record<string, FileOrFolderMixedState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordCheckType {
|
||||||
|
ok: boolean;
|
||||||
|
reason:
|
||||||
|
| "ok"
|
||||||
|
| "empty_remote"
|
||||||
|
| "remote_encrypted_local_no_password"
|
||||||
|
| "password_matched"
|
||||||
|
| "password_not_matched"
|
||||||
|
| "invalid_text_after_decryption"
|
||||||
|
| "remote_not_encrypted_local_has_password"
|
||||||
|
| "no_password_both_sides";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPasswordOk = async (
|
||||||
|
remote: RemoteItem[],
|
||||||
|
password: string = ""
|
||||||
|
) => {
|
||||||
|
if (remote === undefined || remote.length === 0) {
|
||||||
|
// remote empty
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "empty_remote",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
const santyCheckKey = remote[0].key;
|
||||||
|
if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) {
|
||||||
|
// this is encrypted using old base32!
|
||||||
|
// 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 decryptBase32ToString(santyCheckKey, password);
|
||||||
|
|
||||||
|
// additional test
|
||||||
|
// because iOS Safari bypasses decryption with wrong password!
|
||||||
|
if (isVaildText(res)) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "password_matched",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "invalid_text_after_decryption",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "password_not_matched",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE64URL)) {
|
||||||
|
// this is encrypted using new base64url!
|
||||||
|
// try to decrypt it using the provided password.
|
||||||
|
if (password === "") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "remote_encrypted_local_no_password",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await decryptBase64urlToString(santyCheckKey, password);
|
||||||
|
|
||||||
|
// additional test
|
||||||
|
// because iOS Safari bypasses decryption with wrong password!
|
||||||
|
if (isVaildText(res)) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// it is not encrypted!
|
||||||
|
if (password !== "") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "remote_not_encrypted_local_has_password",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "no_password_both_sides",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensembleMixedStates = async (
|
||||||
|
remote: RemoteItem[],
|
||||||
local: TAbstractFile[],
|
local: TAbstractFile[],
|
||||||
deleteHistory: FileFolderHistoryRecord[],
|
deleteHistory: FileFolderHistoryRecord[],
|
||||||
db: lf.DatabaseConnection,
|
db: InternalDBs,
|
||||||
|
remoteType: SUPPORTED_SERVICES_TYPE,
|
||||||
password: string = ""
|
password: string = ""
|
||||||
) => {
|
) => {
|
||||||
const results = {} as Record<string, FileOrFolderMixedState>;
|
const results = {} as Record<string, FileOrFolderMixedState>;
|
||||||
|
|
||||||
if (remote !== undefined) {
|
if (remote !== undefined) {
|
||||||
for (const entry of remote) {
|
for (const entry of remote) {
|
||||||
const remoteEncryptedKey = entry.Key;
|
const remoteEncryptedKey = entry.key;
|
||||||
let key = remoteEncryptedKey;
|
let key = remoteEncryptedKey;
|
||||||
if (password !== "") {
|
if (password !== "") {
|
||||||
key = decryptBase32ToString(remoteEncryptedKey, password);
|
if (remoteEncryptedKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) {
|
||||||
|
key = await decryptBase32ToString(remoteEncryptedKey, password);
|
||||||
|
} else if (
|
||||||
|
remoteEncryptedKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE64URL)
|
||||||
|
) {
|
||||||
|
key = await decryptBase64urlToString(remoteEncryptedKey, password);
|
||||||
|
} else {
|
||||||
|
throw Error(`unexpected key=${remoteEncryptedKey}`);
|
||||||
}
|
}
|
||||||
const backwardMapping = await getSyncMetaMappingByRemoteKeyS3(
|
}
|
||||||
|
const backwardMapping = await getSyncMetaMappingByRemoteKey(
|
||||||
|
remoteType,
|
||||||
db,
|
db,
|
||||||
key,
|
key,
|
||||||
entry.LastModified.valueOf(),
|
entry.lastModified,
|
||||||
entry.ETag
|
entry.etag
|
||||||
);
|
);
|
||||||
|
|
||||||
let r = {} as FileOrFolderMixedState;
|
let r = {} as FileOrFolderMixedState;
|
||||||
if (backwardMapping !== undefined) {
|
if (backwardMapping !== undefined) {
|
||||||
key = backwardMapping.local_key;
|
key = backwardMapping.localKey;
|
||||||
r = {
|
r = {
|
||||||
key: key,
|
key: key,
|
||||||
exist_remote: true,
|
exist_remote: true,
|
||||||
mtime_remote: backwardMapping.local_mtime,
|
mtime_remote: backwardMapping.localMtime || entry.lastModified,
|
||||||
size_remote: backwardMapping.local_size,
|
size_remote: backwardMapping.localSize || entry.size,
|
||||||
remote_encrypted_key: remoteEncryptedKey,
|
remote_encrypted_key: remoteEncryptedKey,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
r = {
|
r = {
|
||||||
key: key,
|
key: key,
|
||||||
exist_remote: true,
|
exist_remote: true,
|
||||||
mtime_remote: entry.LastModified.valueOf(),
|
mtime_remote: entry.lastModified,
|
||||||
size_remote: entry.Size,
|
size_remote: entry.size,
|
||||||
remote_encrypted_key: remoteEncryptedKey,
|
remote_encrypted_key: remoteEncryptedKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (isHiddenPath(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (results.hasOwnProperty(key)) {
|
if (results.hasOwnProperty(key)) {
|
||||||
results[key].key = r.key;
|
results[key].key = r.key;
|
||||||
results[key].exist_remote = r.exist_remote;
|
results[key].exist_remote = r.exist_remote;
|
||||||
@@ -128,6 +258,9 @@ export const ensembleMixedStates = async (
|
|||||||
throw Error(`unexpected ${entry}`);
|
throw Error(`unexpected ${entry}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isHiddenPath(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (results.hasOwnProperty(key)) {
|
if (results.hasOwnProperty(key)) {
|
||||||
results[key].key = r.key;
|
results[key].key = r.key;
|
||||||
results[key].exist_local = r.exist_local;
|
results[key].exist_local = r.exist_local;
|
||||||
@@ -140,11 +273,11 @@ export const ensembleMixedStates = async (
|
|||||||
|
|
||||||
for (const entry of deleteHistory) {
|
for (const entry of deleteHistory) {
|
||||||
let key = entry.key;
|
let key = entry.key;
|
||||||
if (entry.key_type === "folder") {
|
if (entry.keyType === "folder") {
|
||||||
if (!entry.key.endsWith("/")) {
|
if (!entry.key.endsWith("/")) {
|
||||||
key = `${entry.key}/`;
|
key = `${entry.key}/`;
|
||||||
}
|
}
|
||||||
} else if (entry.key_type === "file") {
|
} else if (entry.keyType === "file") {
|
||||||
// pass
|
// pass
|
||||||
} else {
|
} else {
|
||||||
throw Error(`unexpected ${entry}`);
|
throw Error(`unexpected ${entry}`);
|
||||||
@@ -152,9 +285,12 @@ export const ensembleMixedStates = async (
|
|||||||
|
|
||||||
const r = {
|
const r = {
|
||||||
key: key,
|
key: key,
|
||||||
delete_time_local: entry.action_when,
|
delete_time_local: entry.actionWhen,
|
||||||
} as FileOrFolderMixedState;
|
} as FileOrFolderMixedState;
|
||||||
|
|
||||||
|
if (isHiddenPath(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (results.hasOwnProperty(key)) {
|
if (results.hasOwnProperty(key)) {
|
||||||
results[key].key = r.key;
|
results[key].key = r.key;
|
||||||
results[key].delete_time_local = r.delete_time_local;
|
results[key].delete_time_local = r.delete_time_local;
|
||||||
@@ -166,9 +302,10 @@ export const ensembleMixedStates = async (
|
|||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOperation = (
|
const getOperation = (
|
||||||
origRecord: FileOrFolderMixedState,
|
origRecord: FileOrFolderMixedState,
|
||||||
inplace: boolean = false
|
inplace: boolean = false,
|
||||||
|
password: string = ""
|
||||||
) => {
|
) => {
|
||||||
let r = origRecord;
|
let r = origRecord;
|
||||||
if (!inplace) {
|
if (!inplace) {
|
||||||
@@ -216,6 +353,7 @@ export const getOperation = (
|
|||||||
r.mtime_remote !== undefined &&
|
r.mtime_remote !== undefined &&
|
||||||
r.mtime_local !== undefined &&
|
r.mtime_local !== undefined &&
|
||||||
r.mtime_remote === r.mtime_local &&
|
r.mtime_remote === r.mtime_local &&
|
||||||
|
password === "" &&
|
||||||
r.size_local === r.size_remote
|
r.size_local === r.size_remote
|
||||||
) {
|
) {
|
||||||
r.decision = "skip";
|
r.decision = "skip";
|
||||||
@@ -226,17 +364,31 @@ export const getOperation = (
|
|||||||
r.mtime_remote !== undefined &&
|
r.mtime_remote !== undefined &&
|
||||||
r.mtime_local !== undefined &&
|
r.mtime_local !== undefined &&
|
||||||
r.mtime_remote === r.mtime_local &&
|
r.mtime_remote === r.mtime_local &&
|
||||||
|
password === "" &&
|
||||||
r.size_local !== r.size_remote
|
r.size_local !== r.size_remote
|
||||||
) {
|
) {
|
||||||
r.decision = "upload_clearhist";
|
r.decision = "upload_clearhist";
|
||||||
r.decision_branch = 4;
|
r.decision_branch = 4;
|
||||||
|
} else if (
|
||||||
|
r.exist_remote &&
|
||||||
|
r.exist_local &&
|
||||||
|
r.mtime_remote !== undefined &&
|
||||||
|
r.mtime_local !== undefined &&
|
||||||
|
r.mtime_remote === r.mtime_local &&
|
||||||
|
password !== ""
|
||||||
|
) {
|
||||||
|
// if we have encryption,
|
||||||
|
// the size is always unequal
|
||||||
|
// only mtime(s) are reliable
|
||||||
|
r.decision = "skip";
|
||||||
|
r.decision_branch = 5;
|
||||||
} else if (r.exist_remote && r.exist_local && r.mtime_local === undefined) {
|
} else if (r.exist_remote && r.exist_local && r.mtime_local === undefined) {
|
||||||
// this must be a folder!
|
// this must be a folder!
|
||||||
if (!r.key.endsWith("/")) {
|
if (!r.key.endsWith("/")) {
|
||||||
throw Error(`${r.key} is not a folder but lacks local mtime`);
|
throw Error(`${r.key} is not a folder but lacks local mtime`);
|
||||||
}
|
}
|
||||||
r.decision = "skip";
|
r.decision = "skip";
|
||||||
r.decision_branch = 5;
|
r.decision_branch = 6;
|
||||||
} else if (
|
} else if (
|
||||||
r.exist_remote &&
|
r.exist_remote &&
|
||||||
!r.exist_local &&
|
!r.exist_local &&
|
||||||
@@ -246,7 +398,7 @@ export const getOperation = (
|
|||||||
r.mtime_remote >= r.delete_time_local
|
r.mtime_remote >= r.delete_time_local
|
||||||
) {
|
) {
|
||||||
r.decision = "download_clearhist";
|
r.decision = "download_clearhist";
|
||||||
r.decision_branch = 6;
|
r.decision_branch = 7;
|
||||||
} else if (
|
} else if (
|
||||||
r.exist_remote &&
|
r.exist_remote &&
|
||||||
!r.exist_local &&
|
!r.exist_local &&
|
||||||
@@ -256,7 +408,7 @@ export const getOperation = (
|
|||||||
r.mtime_remote < r.delete_time_local
|
r.mtime_remote < r.delete_time_local
|
||||||
) {
|
) {
|
||||||
r.decision = "delremote_clearhist";
|
r.decision = "delremote_clearhist";
|
||||||
r.decision_branch = 7;
|
r.decision_branch = 8;
|
||||||
} else if (
|
} else if (
|
||||||
r.exist_remote &&
|
r.exist_remote &&
|
||||||
!r.exist_local &&
|
!r.exist_local &&
|
||||||
@@ -265,10 +417,10 @@ export const getOperation = (
|
|||||||
r.delete_time_local == undefined
|
r.delete_time_local == undefined
|
||||||
) {
|
) {
|
||||||
r.decision = "download";
|
r.decision = "download";
|
||||||
r.decision_branch = 8;
|
r.decision_branch = 9;
|
||||||
} else if (!r.exist_remote && r.exist_local && r.mtime_remote === undefined) {
|
} else if (!r.exist_remote && r.exist_local && r.mtime_remote === undefined) {
|
||||||
r.decision = "upload_clearhist";
|
r.decision = "upload_clearhist";
|
||||||
r.decision_branch = 9;
|
r.decision_branch = 10;
|
||||||
} else if (
|
} else if (
|
||||||
!r.exist_remote &&
|
!r.exist_remote &&
|
||||||
!r.exist_local &&
|
!r.exist_local &&
|
||||||
@@ -276,30 +428,60 @@ export const getOperation = (
|
|||||||
r.mtime_local === undefined
|
r.mtime_local === undefined
|
||||||
) {
|
) {
|
||||||
r.decision = "clearhist";
|
r.decision = "clearhist";
|
||||||
r.decision_branch = 10;
|
r.decision_branch = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.decision === "unknown") {
|
||||||
|
throw Error(`unknown decision for ${JSON.stringify(r)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return r;
|
return r;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const doActualSync = async (
|
export const getSyncPlan = async (
|
||||||
s3Client: S3Client,
|
remote: RemoteItem[],
|
||||||
s3Config: S3Config,
|
local: TAbstractFile[],
|
||||||
db: lf.DatabaseConnection,
|
deleteHistory: FileFolderHistoryRecord[],
|
||||||
vault: Vault,
|
db: InternalDBs,
|
||||||
keyStates: Record<string, FileOrFolderMixedState>,
|
remoteType: SUPPORTED_SERVICES_TYPE,
|
||||||
password: string = ""
|
password: string = ""
|
||||||
) => {
|
) => {
|
||||||
Object.entries(keyStates)
|
const mixedStates = await ensembleMixedStates(
|
||||||
.sort((k, v) => -(k as string).length)
|
remote,
|
||||||
.map(async ([k, v]) => {
|
local,
|
||||||
const key = k as string;
|
deleteHistory,
|
||||||
const state = v as FileOrFolderMixedState;
|
db,
|
||||||
|
remoteType,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
for (const [key, val] of Object.entries(mixedStates)) {
|
||||||
|
getOperation(val, true, password);
|
||||||
|
}
|
||||||
|
const plan = {
|
||||||
|
ts: Date.now(),
|
||||||
|
remoteType: remoteType,
|
||||||
|
mixedStates: mixedStates,
|
||||||
|
} as SyncPlanType;
|
||||||
|
return plan;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchOperationToActual = async (
|
||||||
|
key: string,
|
||||||
|
state: FileOrFolderMixedState,
|
||||||
|
client: RemoteClient,
|
||||||
|
db: InternalDBs,
|
||||||
|
vault: Vault,
|
||||||
|
password: string = "",
|
||||||
|
foldersCreatedBefore: Set<string> | undefined = undefined
|
||||||
|
) => {
|
||||||
let remoteEncryptedKey = key;
|
let remoteEncryptedKey = key;
|
||||||
if (password !== "") {
|
if (password !== "") {
|
||||||
remoteEncryptedKey = state.remote_encrypted_key;
|
remoteEncryptedKey = state.remote_encrypted_key;
|
||||||
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||||
remoteEncryptedKey = encryptStringToBase32(key, password);
|
// the old version uses base32
|
||||||
|
// remoteEncryptedKey = await encryptStringToBase32(key, password);
|
||||||
|
// the new version users base64url
|
||||||
|
remoteEncryptedKey = await encryptStringToBase64url(key, password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,9 +494,7 @@ export const doActualSync = async (
|
|||||||
} else if (state.decision === "skip") {
|
} else if (state.decision === "skip") {
|
||||||
// do nothing
|
// do nothing
|
||||||
} else if (state.decision === "download_clearhist") {
|
} else if (state.decision === "download_clearhist") {
|
||||||
await downloadFromRemote(
|
await client.downloadFromRemote(
|
||||||
s3Client,
|
|
||||||
s3Config,
|
|
||||||
state.key,
|
state.key,
|
||||||
vault,
|
vault,
|
||||||
state.mtime_remote,
|
state.mtime_remote,
|
||||||
@@ -323,31 +503,29 @@ export const doActualSync = async (
|
|||||||
);
|
);
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||||
} else if (state.decision === "upload_clearhist") {
|
} else if (state.decision === "upload_clearhist") {
|
||||||
const remoteObjMeta = await uploadToRemote(
|
const remoteObjMeta = await client.uploadToRemote(
|
||||||
s3Client,
|
|
||||||
s3Config,
|
|
||||||
state.key,
|
state.key,
|
||||||
vault,
|
vault,
|
||||||
false,
|
false,
|
||||||
password,
|
password,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey,
|
||||||
|
foldersCreatedBefore
|
||||||
);
|
);
|
||||||
await upsertSyncMetaMappingDataS3(
|
await upsertSyncMetaMappingData(
|
||||||
|
client.serviceType,
|
||||||
db,
|
db,
|
||||||
state.key,
|
state.key,
|
||||||
state.mtime_local,
|
state.mtime_local,
|
||||||
state.size_local,
|
state.size_local,
|
||||||
state.key,
|
state.key,
|
||||||
remoteObjMeta.LastModified.valueOf(),
|
remoteObjMeta.lastModified,
|
||||||
remoteObjMeta.ContentLength,
|
remoteObjMeta.size,
|
||||||
remoteObjMeta.ETag
|
remoteObjMeta.etag
|
||||||
);
|
);
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||||
} else if (state.decision === "download") {
|
} else if (state.decision === "download") {
|
||||||
await mkdirpInVault(state.key, vault);
|
await mkdirpInVault(state.key, vault);
|
||||||
await downloadFromRemote(
|
await client.downloadFromRemote(
|
||||||
s3Client,
|
|
||||||
s3Config,
|
|
||||||
state.key,
|
state.key,
|
||||||
vault,
|
vault,
|
||||||
state.mtime_remote,
|
state.mtime_remote,
|
||||||
@@ -355,32 +533,73 @@ export const doActualSync = async (
|
|||||||
remoteEncryptedKey
|
remoteEncryptedKey
|
||||||
);
|
);
|
||||||
} else if (state.decision === "delremote_clearhist") {
|
} else if (state.decision === "delremote_clearhist") {
|
||||||
await deleteFromRemote(s3Client, s3Config, state.key);
|
await client.deleteFromRemote(state.key, password, remoteEncryptedKey);
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||||
} else if (state.decision === "upload") {
|
} else if (state.decision === "upload") {
|
||||||
const remoteObjMeta = await uploadToRemote(
|
const remoteObjMeta = await client.uploadToRemote(
|
||||||
s3Client,
|
|
||||||
s3Config,
|
|
||||||
state.key,
|
state.key,
|
||||||
vault,
|
vault,
|
||||||
false,
|
false,
|
||||||
password,
|
password,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey,
|
||||||
|
foldersCreatedBefore
|
||||||
);
|
);
|
||||||
await upsertSyncMetaMappingDataS3(
|
await upsertSyncMetaMappingData(
|
||||||
|
client.serviceType,
|
||||||
db,
|
db,
|
||||||
state.key,
|
state.key,
|
||||||
state.mtime_local,
|
state.mtime_local,
|
||||||
state.size_local,
|
state.size_local,
|
||||||
state.key,
|
state.key,
|
||||||
remoteObjMeta.LastModified.valueOf(),
|
remoteObjMeta.lastModified,
|
||||||
remoteObjMeta.ContentLength,
|
remoteObjMeta.size,
|
||||||
remoteObjMeta.ETag
|
remoteObjMeta.etag
|
||||||
);
|
);
|
||||||
} else if (state.decision === "clearhist") {
|
} else if (state.decision === "clearhist") {
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||||
} else {
|
} else {
|
||||||
throw Error("this should never happen!");
|
throw Error("this should never happen!");
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
export const doActualSync = async (
|
||||||
|
client: RemoteClient,
|
||||||
|
db: InternalDBs,
|
||||||
|
vault: Vault,
|
||||||
|
syncPlan: SyncPlanType,
|
||||||
|
password: string = ""
|
||||||
|
) => {
|
||||||
|
const keyStates = syncPlan.mixedStates;
|
||||||
|
const foldersCreatedBefore = new Set<string>();
|
||||||
|
for (const [k, v] of Object.entries(keyStates).sort(
|
||||||
|
([k1, v1], [k2, v2]) => k2.length - k1.length
|
||||||
|
)) {
|
||||||
|
const k2 = k as string;
|
||||||
|
const v2 = v as FileOrFolderMixedState;
|
||||||
|
log.debug(`start syncing "${k2}" with plan ${JSON.stringify(v2)}`);
|
||||||
|
await dispatchOperationToActual(
|
||||||
|
k2,
|
||||||
|
v2,
|
||||||
|
client,
|
||||||
|
db,
|
||||||
|
vault,
|
||||||
|
password,
|
||||||
|
foldersCreatedBefore
|
||||||
|
);
|
||||||
|
// log.info(`finished ${k}, with ${setToString(foldersCreatedBefore)}`);
|
||||||
|
}
|
||||||
|
// await Promise.all(
|
||||||
|
// Object.entries(keyStates)
|
||||||
|
// .map(async ([k, v]) =>
|
||||||
|
// dispatchOperationToActual(
|
||||||
|
// k as string,
|
||||||
|
// v as FileOrFolderMixedState,
|
||||||
|
// client,
|
||||||
|
// db,
|
||||||
|
// vault,
|
||||||
|
// password,
|
||||||
|
// foldersCreatedBefore
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// );
|
||||||
};
|
};
|
||||||
|
|||||||
+57
@@ -1 +1,58 @@
|
|||||||
/* set the styles */
|
/* set the styles */
|
||||||
|
|
||||||
|
.password-second-confirm {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-disclaimer {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s3-disclaimer {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.s3-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropbox-disclaimer {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.dropbox-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropbox-auth-button-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropbox-revoke-auth-button-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onedrive-disclaimer {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.onedrive-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onedrive-auth-button-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onedrive-revoke-auth-button-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webdav-disclaimer {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.webdav-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-img {
|
||||||
|
width: 350px;
|
||||||
|
height: 350px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import * as chai from "chai";
|
||||||
|
import chaiAsPromised from "chai-as-promised";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import {
|
||||||
|
decryptArrayBuffer,
|
||||||
|
decryptBase32ToString,
|
||||||
|
encryptArrayBuffer,
|
||||||
|
encryptStringToBase32,
|
||||||
|
encryptStringToBase64url,
|
||||||
|
} from "../src/encrypt";
|
||||||
|
import { base64ToBase64url, bufferToArrayBuffer } from "../src/misc";
|
||||||
|
|
||||||
|
chai.use(chaiAsPromised);
|
||||||
|
const expect = chai.expect;
|
||||||
|
|
||||||
|
describe("Encryption tests", () => {
|
||||||
|
beforeEach(function () {
|
||||||
|
global.window = {
|
||||||
|
crypto: require("crypto").webcrypto,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt string", async () => {
|
||||||
|
const k = "dkjdhkfhdkjgsdklxxd";
|
||||||
|
const password = "hey";
|
||||||
|
expect(await encryptStringToBase32(k, password)).to.not.equal(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should raise error using different password", async () => {
|
||||||
|
const k = "secret text";
|
||||||
|
const password = "hey";
|
||||||
|
const password2 = "hey2";
|
||||||
|
const enc = await encryptStringToBase32(k, password);
|
||||||
|
await expect(decryptBase32ToString(enc, password2)).to.be.rejected;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt and decrypt string and get the same result returned", async () => {
|
||||||
|
const k = "jfkkjkjbce7983ycdeknkkjckooAIUHIDIBIE((*BII)njD/d/dd/d/sjxhux";
|
||||||
|
const password = "hfiuibec989###oiu982bj1`";
|
||||||
|
const enc = await encryptStringToBase32(k, password);
|
||||||
|
// console.log(enc);
|
||||||
|
const dec = await decryptBase32ToString(enc, password);
|
||||||
|
// console.log(dec);
|
||||||
|
expect(dec).equal(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt text file and get the same result as openssl", async () => {
|
||||||
|
const fileContent = (
|
||||||
|
await fs.readFileSync(
|
||||||
|
path.join(__dirname, "static_assets", "sometext.txt")
|
||||||
|
)
|
||||||
|
).toString("utf-8");
|
||||||
|
const password = "somepassword";
|
||||||
|
const saltHex = "8302F586FAB491EC";
|
||||||
|
const enc = await encryptStringToBase64url(
|
||||||
|
fileContent,
|
||||||
|
password,
|
||||||
|
undefined,
|
||||||
|
saltHex
|
||||||
|
);
|
||||||
|
|
||||||
|
// two command returns same result:
|
||||||
|
// cat ./sometext.txt | openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -base64 -pass pass:somepassword
|
||||||
|
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -base64 -pass pass:somepassword -in ./sometext.txt
|
||||||
|
const opensslBase64Res =
|
||||||
|
"U2FsdGVkX1+DAvWG+rSR7BPXMnlvSSVGMdjsx7kE1CTH+28P+yAZRdDGgFWMGkMd";
|
||||||
|
// we output base32, so we need some transformation
|
||||||
|
const opensslBase64urlRes = base64ToBase64url(opensslBase64Res);
|
||||||
|
|
||||||
|
expect(enc).equal(opensslBase64urlRes);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt binary file and get the same result as openssl", async () => {
|
||||||
|
const testFolder = path.join(__dirname, "static_assets", "mona_lisa");
|
||||||
|
const testFileName =
|
||||||
|
"1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg";
|
||||||
|
const fileArrBuf = bufferToArrayBuffer(
|
||||||
|
await fs.readFileSync(path.join(testFolder, testFileName))
|
||||||
|
);
|
||||||
|
const password = "somepassword";
|
||||||
|
const saltHex = "8302F586FAB491EC";
|
||||||
|
const enc = await encryptArrayBuffer(
|
||||||
|
fileArrBuf,
|
||||||
|
password,
|
||||||
|
undefined,
|
||||||
|
saltHex
|
||||||
|
);
|
||||||
|
const opensslArrBuf = bufferToArrayBuffer(
|
||||||
|
await fs.readFileSync(path.join(testFolder, testFileName + ".enc"))
|
||||||
|
);
|
||||||
|
|
||||||
|
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -pass pass:somepassword -in mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg -out mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg.enc
|
||||||
|
|
||||||
|
expect(Buffer.from(enc).equals(Buffer.from(opensslArrBuf))).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should descypt binary file and get the same result as openssl", async () => {
|
||||||
|
const testFolder = path.join(__dirname, "static_assets", "mona_lisa");
|
||||||
|
const testFileName =
|
||||||
|
"1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg";
|
||||||
|
const fileArrBuf = bufferToArrayBuffer(
|
||||||
|
await fs.readFileSync(path.join(testFolder, testFileName + ".enc"))
|
||||||
|
);
|
||||||
|
const password = "somepassword";
|
||||||
|
const dec = await decryptArrayBuffer(fileArrBuf, password);
|
||||||
|
const opensslArrBuf = bufferToArrayBuffer(
|
||||||
|
await fs.readFileSync(path.join(testFolder, testFileName))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Buffer.from(dec).equals(Buffer.from(opensslArrBuf))).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { expect } from "chai";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import * as misc from "../src/misc";
|
||||||
|
|
||||||
|
describe("Misc: hidden file", () => {
|
||||||
|
it("should find hidden file correctly", () => {
|
||||||
|
let item = "";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.false;
|
||||||
|
|
||||||
|
item = ".";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.false;
|
||||||
|
|
||||||
|
item = "..";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.false;
|
||||||
|
|
||||||
|
item = "/x/y/z/../././../a/b/c";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.false;
|
||||||
|
|
||||||
|
item = ".hidden";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.true;
|
||||||
|
|
||||||
|
item = "_hidden_loose";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.true;
|
||||||
|
expect(misc.isHiddenPath(item, false)).to.be.false;
|
||||||
|
|
||||||
|
item = "/sdd/_hidden_loose";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.true;
|
||||||
|
|
||||||
|
item = "what/../_hidden_loose/what/what/what";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.true;
|
||||||
|
|
||||||
|
item = "what/../_hidden_loose/what/what/what";
|
||||||
|
expect(misc.isHiddenPath(item, false)).to.be.false;
|
||||||
|
|
||||||
|
item = "what/../_hidden_loose/../.hidden/what/what/what";
|
||||||
|
expect(misc.isHiddenPath(item, false)).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Misc: get folder levels", () => {
|
||||||
|
it("should ignore empty path", () => {
|
||||||
|
const item = "";
|
||||||
|
expect(misc.getFolderLevels(item)).to.be.empty;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore single file", () => {
|
||||||
|
const item = "xxx";
|
||||||
|
expect(misc.getFolderLevels(item)).to.be.empty;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect path ending with /", () => {
|
||||||
|
const item = "xxx/";
|
||||||
|
const res = ["xxx"];
|
||||||
|
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should correctly split folders and files", () => {
|
||||||
|
const item = "xxx/yyy/zzz.md";
|
||||||
|
const res = ["xxx", "xxx/yyy"];
|
||||||
|
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
||||||
|
|
||||||
|
const item2 = "xxx/yyy/zzz";
|
||||||
|
const res2 = ["xxx", "xxx/yyy"];
|
||||||
|
expect(misc.getFolderLevels(item2)).to.deep.equal(res2);
|
||||||
|
|
||||||
|
const item3 = "xxx/yyy/zzz/";
|
||||||
|
const res3 = ["xxx", "xxx/yyy", "xxx/yyy/zzz"];
|
||||||
|
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should treat path starting with / correctly", () => {
|
||||||
|
const item = "/xxx/yyy/zzz.md";
|
||||||
|
const res = ["/xxx", "/xxx/yyy"];
|
||||||
|
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
||||||
|
|
||||||
|
const item2 = "/xxx/yyy/zzz";
|
||||||
|
const res2 = ["/xxx", "/xxx/yyy"];
|
||||||
|
expect(misc.getFolderLevels(item2)).to.deep.equal(res2);
|
||||||
|
|
||||||
|
const item3 = "/xxx/yyy/zzz/";
|
||||||
|
const res3 = ["/xxx", "/xxx/yyy", "/xxx/yyy/zzz"];
|
||||||
|
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
|
||||||
|
|
||||||
|
const item4 = "/xxx";
|
||||||
|
const res4 = [] as string[];
|
||||||
|
expect(misc.getFolderLevels(item4)).to.deep.equal(res4);
|
||||||
|
|
||||||
|
const item5 = "/";
|
||||||
|
const res5 = [] as string[];
|
||||||
|
expect(misc.getFolderLevels(item5)).to.deep.equal(res5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Misc: vaild file name tests", () => {
|
||||||
|
it("should treat no ascii correctly", async () => {
|
||||||
|
const x = misc.isVaildText("😄🍎 apple 苹果");
|
||||||
|
// console.log(x)
|
||||||
|
expect(x).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find not-printable chars correctly", async () => {
|
||||||
|
const x = misc.isVaildText("😄🍎 apple 苹果\u0000");
|
||||||
|
// console.log(x)
|
||||||
|
expect(x).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow spaces/slashes/...", async () => {
|
||||||
|
const x = misc.isVaildText("😄🍎 apple 苹果/-_=/\\*%^&@#$`");
|
||||||
|
expect(x).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Misc: get dirname", () => {
|
||||||
|
it("should return itself for folder", async () => {
|
||||||
|
const x = misc.getPathFolder("ssss/");
|
||||||
|
// console.log(x)
|
||||||
|
expect(x).to.equal("ssss/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return folder for file", async () => {
|
||||||
|
const x = misc.getPathFolder("sss/yyy");
|
||||||
|
// console.log(x)
|
||||||
|
expect(x).to.equal("sss/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should treat / specially", async () => {
|
||||||
|
const x = misc.getPathFolder("/");
|
||||||
|
expect(x).to.equal("/");
|
||||||
|
|
||||||
|
const y = misc.getPathFolder("/abc");
|
||||||
|
expect(y).to.equal("/");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Misc: extract svg", () => {
|
||||||
|
beforeEach(function () {
|
||||||
|
const fakeBrowser = new JSDOM("");
|
||||||
|
global.window = fakeBrowser.window as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract rect from svg correctly", () => {
|
||||||
|
const x = "<svg><rect/><g/></svg>";
|
||||||
|
const y = misc.extractSvgSub(x);
|
||||||
|
// console.log(x)
|
||||||
|
expect(y).to.equal("<rect/><g/>");
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
The file 1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg is downloaded from Wikimedia Commons: https://commons.wikimedia.org/wiki/File:Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg .
|
||||||
|
|
||||||
|
Quoted from the web address: ... This photographic reproduction is therefore also considered to be in the public domain in the United States. In other jurisdictions, re-use of this content may be restricted ...
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b332c298be98be9968715952822431a051f9d07e67460cdcf91a5a3a305b5df1
|
||||||
|
size 1141550
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7687eaf60ed93f754b107dc86a437aa97fad7bedd40432171b08c22cded7b559
|
||||||
|
size 1141568
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# The encryption file is produced by the following command.
|
||||||
|
# A salt is explictly provided because we need reproducible output in tests.
|
||||||
|
openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 10000 -pass pass:somepassword -in '1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg' -out 1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg.enc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
A secret text 你好世界
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
// "allowSyntheticDefaultImports": true,
|
// "allowSyntheticDefaultImports": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
|
"isolatedModules": true,
|
||||||
"lib": ["dom", "es5", "scripthost", "es2015"]
|
"lib": ["dom", "es5", "scripthost", "es2015"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"0.0.4": "0.12.15"
|
"0.2.8": "0.12.15"
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-19
@@ -1,7 +1,12 @@
|
|||||||
|
require("dotenv").config();
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const webpack = require("webpack");
|
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_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",
|
||||||
target: "web",
|
target: "web",
|
||||||
@@ -11,6 +16,11 @@ module.exports = {
|
|||||||
libraryTarget: "commonjs",
|
libraryTarget: "commonjs",
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
"process.env.DEFAULT_DROPBOX_APP_KEY": `"${DEFAULT_DROPBOX_APP_KEY}"`,
|
||||||
|
"process.env.DEFAULT_ONEDRIVE_CLIENT_ID": `"${DEFAULT_ONEDRIVE_CLIENT_ID}"`,
|
||||||
|
"process.env.DEFAULT_ONEDRIVE_AUTHORITY": `"${DEFAULT_ONEDRIVE_AUTHORITY}"`,
|
||||||
|
}),
|
||||||
// Work around for Buffer is undefined:
|
// Work around for Buffer is undefined:
|
||||||
// https://github.com/webpack/changelog-v5/issues/10
|
// https://github.com/webpack/changelog-v5/issues/10
|
||||||
new webpack.ProvidePlugin({
|
new webpack.ProvidePlugin({
|
||||||
@@ -27,35 +37,40 @@ module.exports = {
|
|||||||
use: "ts-loader",
|
use: "ts-loader",
|
||||||
exclude: /node_modules/,
|
exclude: /node_modules/,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.svg?$/,
|
||||||
|
type: "asset/source",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: [".tsx", ".ts", ".js"],
|
extensions: [".tsx", ".ts", ".js"],
|
||||||
mainFields: ["browser", "module", "main"],
|
mainFields: ["browser", "module", "main"],
|
||||||
fallback: {
|
fallback: {
|
||||||
assert: require.resolve("assert"),
|
// assert: require.resolve("assert"),
|
||||||
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"),
|
||||||
domain: require.resolve("domain-browser"),
|
// crypto: false,
|
||||||
events: require.resolve("events"),
|
// domain: require.resolve("domain-browser"),
|
||||||
http: require.resolve("stream-http"),
|
// events: require.resolve("events"),
|
||||||
https: require.resolve("https-browserify"),
|
// http: require.resolve("stream-http"),
|
||||||
os: require.resolve("os-browserify/browser"),
|
// https: require.resolve("https-browserify"),
|
||||||
|
// os: require.resolve("os-browserify/browser"),
|
||||||
path: require.resolve("path-browserify"),
|
path: require.resolve("path-browserify"),
|
||||||
punycode: require.resolve("punycode"),
|
// punycode: require.resolve("punycode"),
|
||||||
process: require.resolve("process/browser"),
|
process: require.resolve("process/browser"),
|
||||||
querystring: require.resolve("querystring-es3"),
|
// querystring: require.resolve("querystring-es3"),
|
||||||
stream: require.resolve("stream-browserify"),
|
stream: require.resolve("stream-browserify"),
|
||||||
string_decoder: require.resolve("string_decoder"),
|
// string_decoder: require.resolve("string_decoder"),
|
||||||
sys: require.resolve("util"),
|
// sys: require.resolve("util"),
|
||||||
timers: require.resolve("timers-browserify"),
|
// timers: require.resolve("timers-browserify"),
|
||||||
tty: require.resolve("tty-browserify"),
|
// tty: require.resolve("tty-browserify"),
|
||||||
url: require.resolve("url"),
|
// url: require.resolve("url"),
|
||||||
util: require.resolve("util"),
|
// util: require.resolve("util"),
|
||||||
vm: require.resolve("vm-browserify"),
|
// vm: require.resolve("vm-browserify"),
|
||||||
zlib: require.resolve("browserify-zlib"),
|
// zlib: require.resolve("browserify-zlib"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
externals: {
|
externals: {
|
||||||
|
|||||||
Reference in New Issue
Block a user