Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -13,5 +13,8 @@ main.js
|
||||
# obsidian
|
||||
data.json
|
||||
|
||||
# debug
|
||||
logs.txt
|
||||
|
||||
# hidden files
|
||||
.*
|
||||
|
||||
@@ -1,26 +1,60 @@
|
||||
# Save Remote
|
||||
# Remotely Save
|
||||
|
||||
This is yet another sync plugin for Obsidian.
|
||||
|
||||
## Download and Install
|
||||
|
||||
[](https://github.com/fyears/obsidian-save-remote/actions/workflows/auto-build.yml)
|
||||
|
||||
Every artifacts are placed in the "Summary" under every successful builds.
|
||||
|
||||
Besides manually download the files, you can also use [Obsidian42 - BRAT](https://github.com/TfTHacker/obsidian42-brat) to install this plugin.
|
||||
This is yet another unofficial sync plugin for Obsidian.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**This is NOT the official sync service provided by Obsidian.**
|
||||
- **This is NOT the [official sync service](https://github.com/fyears/obsidian-remotely-save.git) provided by Obsidian.**
|
||||
|
||||
## !!!Caution!!!
|
||||
|
||||
As of October 2021, the plugin is under development. **DO NOT USE IT for any serious vaults.** Prepare for data loss!
|
||||
As of November 2021, the plugin is considered in BETA stage. **DO NOT USE IT for any serious vaults.** **Backup your vault before using this plugin.** Don't be surprise to data loss!
|
||||
|
||||
## Features
|
||||
|
||||
- **Amazon S3 or S3-compatible, and Dropbox services are supported.** Webdav supports on the half way.
|
||||
- **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.
|
||||
|
||||
## Download and Install
|
||||
|
||||
- Option #1: [](https://github.com/fyears/obsidian-remotely-save/actions/workflows/auto-build.yml) Every artifacts are placed in the "Summary" under every successful builds.
|
||||
- Option #2: Besides manually downloading the files, you can also use [Obsidian42 - BRAT](https://github.com/TfTHacker/obsidian42-brat) to install this plugin.
|
||||
- Option #3: The pluin would be submitted to the official "community plugin list" in near future.
|
||||
|
||||
## Usage
|
||||
|
||||
Remote service in support list:
|
||||
### s3
|
||||
|
||||
- [x] AWS S3
|
||||
- [ ] webdav
|
||||
- Prepare your S3 (-compatible) service information: [endpoint, region](https://docs.aws.amazon.com/general/latest/gr/s3.html), [access key id, secret access key](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-your-credentials.html), bucket name. The bucket should be empty and solely for syncing a vault.
|
||||
- Configure (enable) [CORS](https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html) for requests from `app://obsidian.md` and `capacitor://localhost` and `http://localhost`. It's unfortunately required, because the plugin sends requests from a browser-like envirement. And those addresses are tested and found on desktop and ios and android.
|
||||
- Download and enable this plugin.
|
||||
- Enter your infomation 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/obsidian-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.
|
||||
|
||||
### webdav
|
||||
|
||||
- **webdav support is buggy (as of now, 20211122) and considered experimental, so it's hidden by default.** Highly recommend to use the more stable s3.
|
||||
- If you decide to give it a try, open settings, and click "Choose service" area five times, then a Notice should show up. Close and open settings again then you will be able to select webdav.
|
||||
- Currently webdav server should enable CORS for requests, because of technical limitations of mobile.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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 `base32` is applied.
|
||||
Assuming the file path is `a-folder-文件夹/a-file-文件.md`, then the following commands are equivilent:
|
||||
|
||||
```bash
|
||||
# pure string encryption then base32
|
||||
echo -n 'a-folder-文件夹/a-file-文件.md' | openssl enc -aes-256-cbc -pbkdf2 -iter 20000 -pass pass:mylongpassword | base32 -w 0
|
||||
|
||||
# pure string base32 then decryption
|
||||
echo -n 'KNQWY5DFMRPV7UHRWVYFSHE2XVVVZCFN65SR7ETEKO5L6EYGXCVEPT4A2LVTW4W2ZHXWF3K22SVA562CCZ6SALARXJY6AAXXHLK5UOA=' | base32 -d -w 00 | 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.
|
||||
@@ -1,4 +1,4 @@
|
||||
# Algorithm
|
||||
# Sync Algorithm
|
||||
|
||||
## Sources
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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 || "";
|
||||
|
||||
esbuild
|
||||
.build({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["./src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"fs",
|
||||
// ...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}"`,
|
||||
},
|
||||
})
|
||||
.catch(() => process.exit(1));
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"id": "obsidian-save-remote",
|
||||
"name": "Save remote",
|
||||
"version": "0.0.14",
|
||||
"id": "obsidian-remotely-save",
|
||||
"name": "Remotely Save",
|
||||
"version": "0.1.10",
|
||||
"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 sync notes between local device and the cloud service.",
|
||||
"author": "fyears",
|
||||
"authorUrl": "https://github.com/fyears",
|
||||
"isDesktopOnly": false
|
||||
|
||||
+21
-6
@@ -1,14 +1,23 @@
|
||||
{
|
||||
"name": "obsidian-save-remote",
|
||||
"version": "0.0.14",
|
||||
"name": "obsidian-remotely-save",
|
||||
"version": "0.1.10",
|
||||
"description": "This is yet another sync plugin for Obsidian app.",
|
||||
"scripts": {
|
||||
"dev": "webpack --mode development --watch",
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build2": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"build": "webpack --mode production",
|
||||
"format": "npx prettier --write .",
|
||||
"clean": "npx rimraf main.js",
|
||||
"test": "cross-env TS_NODE_COMPILER_OPTIONS={\\\"module\\\":\\\"commonjs\\\"} mocha -r ts-node/register 'tests/**/*.ts'"
|
||||
},
|
||||
"browser": {
|
||||
"path": "path-browserify",
|
||||
"process": "process/browser",
|
||||
"stream": "stream-browserify",
|
||||
"crypto": "crypto-browserify",
|
||||
"util": "util/",
|
||||
"assert": "assert/"
|
||||
},
|
||||
"source": "main.ts",
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -19,18 +28,20 @@
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node": "^14.14.37",
|
||||
"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",
|
||||
"mocha": "^9.1.3",
|
||||
"prettier": "^2.4.1",
|
||||
"terser-webpack-plugin": "^5.2.4",
|
||||
"ts-loader": "^9.2.6",
|
||||
"ts-node": "^10.4.0",
|
||||
"tslib": "^2.2.0",
|
||||
"typescript": "^4.4.4",
|
||||
"webdav-server": "^2.6.2",
|
||||
"webpack": "^5.58.2",
|
||||
"webpack": "^5.64.4",
|
||||
"webpack-cli": "^4.9.1"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -38,10 +49,13 @@
|
||||
"@aws-sdk/lib-storage": "^3.40.1",
|
||||
"@aws-sdk/signature-v4-crt": "^3.37.0",
|
||||
"acorn": "^8.5.0",
|
||||
"assert": "^2.0.0",
|
||||
"aws-crt": "^1.10.1",
|
||||
"buffer": "^6.0.3",
|
||||
"codemirror": "^5.63.1",
|
||||
"lovefield-ts": "^0.7.0",
|
||||
"crypto-browserify": "^3.12.0",
|
||||
"dropbox": "^10.22.0",
|
||||
"localforage": "^1.10.0",
|
||||
"mime-types": "^2.1.33",
|
||||
"obsidian": "^0.12.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
@@ -49,6 +63,7 @@
|
||||
"rfc4648": "^1.5.0",
|
||||
"rimraf": "^3.0.2",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"util": "^0.12.4",
|
||||
"webdav": "^4.7.0",
|
||||
"webdav-fs": "^4.0.0",
|
||||
"xregexp": "^5.1.0"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Only type defs here.
|
||||
*/
|
||||
|
||||
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "dropbox";
|
||||
|
||||
export interface RemoteItem {
|
||||
key: string;
|
||||
lastModified: number;
|
||||
size: number;
|
||||
remoteType: SUPPORTED_SERVICES_TYPE;
|
||||
etag?: string;
|
||||
}
|
||||
+4
-12
@@ -1,22 +1,14 @@
|
||||
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
||||
|
||||
import * as lf from "lovefield-ts/dist/es6/lf.js";
|
||||
|
||||
import type { SyncPlanType } from "./sync";
|
||||
import {
|
||||
insertSyncPlanRecord,
|
||||
clearAllSyncPlanRecords,
|
||||
readAllSyncPlanRecordTexts,
|
||||
} from "./localdb";
|
||||
import { readAllSyncPlanRecordTexts } from "./localdb";
|
||||
import type { InternalDBs } from "./localdb";
|
||||
import { mkdirpInVault } from "./misc";
|
||||
|
||||
const DEFAULT_DEBUG_FOLDER = "_debug_save_remote/";
|
||||
const DEFAULT_DEBUG_FOLDER = "_debug_remotely_save/";
|
||||
const DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX = "sync_plans_hist_exported_on_";
|
||||
|
||||
export const exportSyncPlansToFiles = async (
|
||||
db: lf.DatabaseConnection,
|
||||
vault: Vault
|
||||
) => {
|
||||
export const exportSyncPlansToFiles = async (db: InternalDBs, vault: Vault) => {
|
||||
console.log("exporting");
|
||||
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
||||
const records = await readAllSyncPlanRecordTexts(db);
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
arrayBufferToHex,
|
||||
} from "./misc";
|
||||
|
||||
const DEFAULT_ITER = 10000;
|
||||
const DEFAULT_ITER = 20000;
|
||||
|
||||
// base32.stringify(Buffer.from('Salted__'))
|
||||
export const MAGIC_ENCRYPTED_PREFIX_BASE32 = "KNQWY5DFMRPV";
|
||||
|
||||
+145
-175
@@ -1,12 +1,14 @@
|
||||
import * as lf from "lovefield-ts/dist/es6/lf.js";
|
||||
import localforage from "localforage";
|
||||
import { TAbstractFile, TFile, TFolder } from "obsidian";
|
||||
|
||||
import type { SUPPORTED_SERVICES_TYPE } from "./misc";
|
||||
import type { SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
||||
import type { SyncPlanType } from "./sync";
|
||||
|
||||
export type DatabaseConnection = lf.DatabaseConnection;
|
||||
export type LocalForage = typeof localforage;
|
||||
|
||||
export const DEFAULT_DB_NAME = "saveremotedb";
|
||||
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_SYNC_MAPPING = "syncmetadatahistory";
|
||||
export const DEFAULT_SYNC_PLANS_HISTORY = "syncplanshistory";
|
||||
@@ -16,84 +18,75 @@ export interface FileFolderHistoryRecord {
|
||||
ctime: number;
|
||||
mtime: number;
|
||||
size: number;
|
||||
action_when: number;
|
||||
action_type: "delete" | "rename";
|
||||
key_type: "folder" | "file";
|
||||
rename_to: string;
|
||||
actionWhen: number;
|
||||
actionType: "delete" | "rename";
|
||||
keyType: "folder" | "file";
|
||||
renameTo: string;
|
||||
}
|
||||
|
||||
export interface SyncMetaMappingRecord {
|
||||
local_key: string;
|
||||
remote_key: string;
|
||||
local_size: number;
|
||||
remote_size: number;
|
||||
local_mtime: number;
|
||||
remote_mtime: number;
|
||||
remote_extra_key: string;
|
||||
remote_type: SUPPORTED_SERVICES_TYPE;
|
||||
key_type: "folder" | "file";
|
||||
interface SyncMetaMappingRecord {
|
||||
localKey: string;
|
||||
remoteKey: string;
|
||||
localSize: number;
|
||||
remoteSize: number;
|
||||
localMtime: number;
|
||||
remoteMtime: number;
|
||||
remoteExtraKey: string;
|
||||
remoteType: SUPPORTED_SERVICES_TYPE;
|
||||
keyType: "folder" | "file";
|
||||
}
|
||||
|
||||
interface SyncPlanRecord {
|
||||
ts: number;
|
||||
remote_type: string;
|
||||
sync_plan: string;
|
||||
remoteType: string;
|
||||
syncPlan: string;
|
||||
}
|
||||
|
||||
export interface InternalDBs {
|
||||
versionTbl: LocalForage;
|
||||
deleteHistoryTbl: LocalForage;
|
||||
syncMappingTbl: LocalForage;
|
||||
syncPlansTbl: LocalForage;
|
||||
}
|
||||
|
||||
export const prepareDBs = async () => {
|
||||
const schemaBuilder = lf.schema.create(DEFAULT_DB_NAME, 1);
|
||||
schemaBuilder
|
||||
.createTable(DEFAULT_TBL_DELETE_HISTORY)
|
||||
.addColumn("id", lf.Type.INTEGER)
|
||||
.addColumn("key", lf.Type.STRING)
|
||||
.addColumn("ctime", lf.Type.INTEGER)
|
||||
.addColumn("mtime", lf.Type.INTEGER)
|
||||
.addColumn("size", lf.Type.INTEGER)
|
||||
.addColumn("action_when", lf.Type.INTEGER)
|
||||
.addColumn("action_type", lf.Type.STRING)
|
||||
.addColumn("key_type", lf.Type.STRING)
|
||||
.addPrimaryKey(["id"], true)
|
||||
.addIndex("idxKey", ["key"]);
|
||||
const db = {
|
||||
versionTbl: localforage.createInstance({
|
||||
name: DEFAULT_DB_NAME,
|
||||
storeName: DEFAULT_TBL_VERSION,
|
||||
}),
|
||||
deleteHistoryTbl: localforage.createInstance({
|
||||
name: DEFAULT_DB_NAME,
|
||||
storeName: DEFAULT_TBL_DELETE_HISTORY,
|
||||
}),
|
||||
syncMappingTbl: localforage.createInstance({
|
||||
name: DEFAULT_DB_NAME,
|
||||
storeName: DEFAULT_TBL_SYNC_MAPPING,
|
||||
}),
|
||||
syncPlansTbl: localforage.createInstance({
|
||||
name: DEFAULT_DB_NAME,
|
||||
storeName: DEFAULT_SYNC_PLANS_HISTORY,
|
||||
}),
|
||||
} as InternalDBs;
|
||||
|
||||
schemaBuilder
|
||||
.createTable(DEFAULT_TBL_SYNC_MAPPING)
|
||||
.addColumn("id", lf.Type.INTEGER)
|
||||
.addColumn("local_key", lf.Type.STRING)
|
||||
.addColumn("remote_key", lf.Type.STRING)
|
||||
.addColumn("local_size", lf.Type.INTEGER)
|
||||
.addColumn("remote_size", lf.Type.INTEGER)
|
||||
.addColumn("local_mtime", lf.Type.INTEGER)
|
||||
.addColumn("remote_mtime", lf.Type.INTEGER)
|
||||
.addColumn("key_type", lf.Type.STRING)
|
||||
.addColumn("remote_extra_key", lf.Type.STRING)
|
||||
.addColumn("remote_type", lf.Type.STRING)
|
||||
.addNullable([
|
||||
"remote_extra_key",
|
||||
"remote_mtime",
|
||||
"remote_size",
|
||||
"local_mtime",
|
||||
])
|
||||
.addPrimaryKey(["id"], true)
|
||||
.addIndex("idxkey", ["local_key", "remote_key"]);
|
||||
const originalVersion = (await db.versionTbl.getItem("version")) as number;
|
||||
if (originalVersion === null) {
|
||||
await db.versionTbl.setItem("version", DEFAULT_DB_VERSION_NUMBER);
|
||||
} else if (originalVersion === DEFAULT_DB_VERSION_NUMBER) {
|
||||
// do nothing
|
||||
} else {
|
||||
await migrateDBs(db, originalVersion, DEFAULT_DB_VERSION_NUMBER);
|
||||
}
|
||||
|
||||
schemaBuilder
|
||||
.createTable(DEFAULT_SYNC_PLANS_HISTORY)
|
||||
.addColumn("id", lf.Type.INTEGER)
|
||||
.addColumn("ts", lf.Type.INTEGER)
|
||||
.addColumn("remote_type", lf.Type.STRING)
|
||||
.addColumn("sync_plan", lf.Type.STRING)
|
||||
.addPrimaryKey(["id"], true)
|
||||
.addIndex("tskey", ["ts"]);
|
||||
|
||||
const db = await schemaBuilder.connect({
|
||||
storeType: lf.DataStoreType.INDEXED_DB,
|
||||
});
|
||||
console.log("db connected");
|
||||
return db;
|
||||
};
|
||||
|
||||
export const destroyDBs = async (db: lf.DatabaseConnection) => {
|
||||
db.close();
|
||||
export const destroyDBs = async () => {
|
||||
// await localforage.dropInstance({
|
||||
// name: DEFAULT_DB_NAME,
|
||||
// });
|
||||
// console.log("db deleted");
|
||||
const req = indexedDB.deleteDatabase(DEFAULT_DB_NAME);
|
||||
req.onsuccess = (event) => {
|
||||
console.log("db deleted");
|
||||
@@ -107,37 +100,34 @@ export const destroyDBs = async (db: lf.DatabaseConnection) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const loadDeleteRenameHistoryTable = async (
|
||||
db: lf.DatabaseConnection
|
||||
) => {
|
||||
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
||||
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
||||
const migrateDBs = async (db: InternalDBs, oldVer: number, newVer: number) => {
|
||||
if (oldVer === newVer) {
|
||||
return;
|
||||
}
|
||||
// not implemented
|
||||
throw Error(`not supported internal db changes from ${oldVer} to ${newVer}`);
|
||||
};
|
||||
|
||||
const records = await db
|
||||
.select()
|
||||
.from(schema)
|
||||
.orderBy(schema.col("action_when"), lf.Order.ASC)
|
||||
.exec();
|
||||
|
||||
return records as FileFolderHistoryRecord[];
|
||||
export const loadDeleteRenameHistoryTable = async (db: InternalDBs) => {
|
||||
const records = [] as FileFolderHistoryRecord[];
|
||||
await db.deleteHistoryTbl.iterate((value, key, iterationNumber) => {
|
||||
records.push(value as FileFolderHistoryRecord);
|
||||
});
|
||||
records.sort((a, b) => a.actionWhen - b.actionWhen); // ascending
|
||||
return records;
|
||||
};
|
||||
|
||||
export const clearDeleteRenameHistoryOfKey = async (
|
||||
db: lf.DatabaseConnection,
|
||||
db: InternalDBs,
|
||||
key: string
|
||||
) => {
|
||||
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
||||
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
||||
|
||||
await db.delete().from(tbl).where(tbl.col("key").eq(key)).exec();
|
||||
await db.deleteHistoryTbl.removeItem(key);
|
||||
};
|
||||
|
||||
export const insertDeleteRecord = async (
|
||||
db: lf.DatabaseConnection,
|
||||
db: InternalDBs,
|
||||
fileOrFolder: TAbstractFile
|
||||
) => {
|
||||
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
||||
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
||||
// console.log(fileOrFolder);
|
||||
let k: FileFolderHistoryRecord;
|
||||
if (fileOrFolder instanceof TFile) {
|
||||
@@ -146,10 +136,10 @@ export const insertDeleteRecord = async (
|
||||
ctime: fileOrFolder.stat.ctime,
|
||||
mtime: fileOrFolder.stat.mtime,
|
||||
size: fileOrFolder.stat.size,
|
||||
action_when: Date.now(),
|
||||
action_type: "delete",
|
||||
key_type: "file",
|
||||
rename_to: "",
|
||||
actionWhen: Date.now(),
|
||||
actionType: "delete",
|
||||
keyType: "file",
|
||||
renameTo: "",
|
||||
};
|
||||
} else if (fileOrFolder instanceof TFolder) {
|
||||
// key should endswith "/"
|
||||
@@ -161,23 +151,20 @@ export const insertDeleteRecord = async (
|
||||
ctime: 0,
|
||||
mtime: 0,
|
||||
size: 0,
|
||||
action_when: Date.now(),
|
||||
action_type: "delete",
|
||||
key_type: "folder",
|
||||
rename_to: "",
|
||||
actionWhen: Date.now(),
|
||||
actionType: "delete",
|
||||
keyType: "folder",
|
||||
renameTo: "",
|
||||
};
|
||||
}
|
||||
const row = tbl.createRow(k);
|
||||
await db.insertOrReplace().into(tbl).values([row]).exec();
|
||||
await db.deleteHistoryTbl.setItem(k.key, k);
|
||||
};
|
||||
|
||||
export const insertRenameRecord = async (
|
||||
db: lf.DatabaseConnection,
|
||||
db: InternalDBs,
|
||||
fileOrFolder: TAbstractFile,
|
||||
oldPath: string
|
||||
) => {
|
||||
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
||||
const tbl = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
||||
// console.log(fileOrFolder);
|
||||
let k: FileFolderHistoryRecord;
|
||||
if (fileOrFolder instanceof TFile) {
|
||||
@@ -186,10 +173,10 @@ export const insertRenameRecord = async (
|
||||
ctime: fileOrFolder.stat.ctime,
|
||||
mtime: fileOrFolder.stat.mtime,
|
||||
size: fileOrFolder.stat.size,
|
||||
action_when: Date.now(),
|
||||
action_type: "rename",
|
||||
key_type: "file",
|
||||
rename_to: fileOrFolder.path,
|
||||
actionWhen: Date.now(),
|
||||
actionType: "rename",
|
||||
keyType: "file",
|
||||
renameTo: fileOrFolder.path,
|
||||
};
|
||||
} else if (fileOrFolder instanceof TFolder) {
|
||||
const key = oldPath.endsWith("/") ? oldPath : `${oldPath}/`;
|
||||
@@ -201,25 +188,18 @@ export const insertRenameRecord = async (
|
||||
ctime: 0,
|
||||
mtime: 0,
|
||||
size: 0,
|
||||
action_when: Date.now(),
|
||||
action_type: "rename",
|
||||
key_type: "folder",
|
||||
rename_to: renameTo,
|
||||
actionWhen: Date.now(),
|
||||
actionType: "rename",
|
||||
keyType: "folder",
|
||||
renameTo: renameTo,
|
||||
};
|
||||
}
|
||||
const row = tbl.createRow(k);
|
||||
await db.insertOrReplace().into(tbl).values([row]).exec();
|
||||
await db.deleteHistoryTbl.setItem(k.key, k);
|
||||
};
|
||||
|
||||
export const getAllDeleteRenameRecords = async (db: lf.DatabaseConnection) => {
|
||||
const schema = db.getSchema().table(DEFAULT_TBL_DELETE_HISTORY);
|
||||
const res1 = await db.select().from(schema).exec();
|
||||
const res2 = res1 as FileFolderHistoryRecord[];
|
||||
return res2;
|
||||
};
|
||||
|
||||
export const upsertSyncMetaMappingDataS3 = async (
|
||||
db: lf.DatabaseConnection,
|
||||
export const upsertSyncMetaMappingData = async (
|
||||
serviceType: SUPPORTED_SERVICES_TYPE,
|
||||
db: InternalDBs,
|
||||
localKey: string,
|
||||
localMTime: number,
|
||||
localSize: number,
|
||||
@@ -228,89 +208,79 @@ export const upsertSyncMetaMappingDataS3 = async (
|
||||
remoteSize: number,
|
||||
remoteExtraKey: string /* ETag from s3 */
|
||||
) => {
|
||||
const schema = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
|
||||
const aggregratedInfo: SyncMetaMappingRecord = {
|
||||
local_key: localKey,
|
||||
local_mtime: localMTime,
|
||||
local_size: localSize,
|
||||
remote_key: remoteKey,
|
||||
remote_mtime: remoteMTime,
|
||||
remote_size: remoteSize,
|
||||
remote_extra_key: remoteExtraKey,
|
||||
remote_type: "s3",
|
||||
key_type: localKey.endsWith("/") ? "folder" : "file",
|
||||
localKey: localKey,
|
||||
localMtime: localMTime,
|
||||
localSize: localSize,
|
||||
remoteKey: remoteKey,
|
||||
remoteMtime: remoteMTime,
|
||||
remoteSize: remoteSize,
|
||||
remoteExtraKey: remoteExtraKey,
|
||||
remoteType: serviceType,
|
||||
keyType: localKey.endsWith("/") ? "folder" : "file",
|
||||
};
|
||||
const row = schema.createRow(aggregratedInfo);
|
||||
await db.insertOrReplace().into(schema).values([row]).exec();
|
||||
await db.syncMappingTbl.setItem(remoteKey, aggregratedInfo);
|
||||
};
|
||||
|
||||
export const getSyncMetaMappingByRemoteKeyS3 = async (
|
||||
db: lf.DatabaseConnection,
|
||||
export const getSyncMetaMappingByRemoteKey = async (
|
||||
serviceType: SUPPORTED_SERVICES_TYPE,
|
||||
db: InternalDBs,
|
||||
remoteKey: string,
|
||||
remoteMTime: number,
|
||||
remoteExtraKey: string
|
||||
) => {
|
||||
const schema = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
|
||||
const tbl = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
|
||||
const res = (await db
|
||||
.select()
|
||||
.from(tbl)
|
||||
.where(
|
||||
lf.op.and(
|
||||
tbl.col("remote_key").eq(remoteKey),
|
||||
tbl.col("remote_mtime").eq(remoteMTime),
|
||||
tbl.col("remote_extra_key").eq(remoteExtraKey),
|
||||
tbl.col("remote_type").eq("s3")
|
||||
)
|
||||
)
|
||||
.exec()) as SyncMetaMappingRecord[];
|
||||
const potentialItem = (await db.syncMappingTbl.getItem(
|
||||
remoteKey
|
||||
)) as SyncMetaMappingRecord;
|
||||
|
||||
if (res.length === 1) {
|
||||
return res[0];
|
||||
}
|
||||
|
||||
if (res.length === 0) {
|
||||
if (potentialItem === null) {
|
||||
// no result was found
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw Error("something bad in sync meta mapping!");
|
||||
if (
|
||||
potentialItem.remoteKey === remoteKey &&
|
||||
potentialItem.remoteMtime === remoteMTime &&
|
||||
potentialItem.remoteExtraKey === remoteExtraKey &&
|
||||
potentialItem.remoteType === serviceType
|
||||
) {
|
||||
// the result was found
|
||||
return potentialItem;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const clearAllSyncMetaMapping = async (db: lf.DatabaseConnection) => {
|
||||
const tbl = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
|
||||
await db.delete().from(tbl).exec();
|
||||
export const clearAllSyncMetaMapping = async (db: InternalDBs) => {
|
||||
await db.syncMappingTbl.clear();
|
||||
};
|
||||
|
||||
export const insertSyncPlanRecord = async (
|
||||
db: lf.DatabaseConnection,
|
||||
db: InternalDBs,
|
||||
syncPlan: SyncPlanType
|
||||
) => {
|
||||
const schema = db.getSchema().table(DEFAULT_SYNC_PLANS_HISTORY);
|
||||
const row = schema.createRow({
|
||||
const record = {
|
||||
ts: syncPlan.ts,
|
||||
remote_type: syncPlan.remoteType,
|
||||
sync_plan: JSON.stringify(syncPlan, null, 2),
|
||||
} as SyncPlanRecord);
|
||||
await db.insertOrReplace().into(schema).values([row]).exec();
|
||||
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: lf.DatabaseConnection) => {
|
||||
const tbl = db.getSchema().table(DEFAULT_SYNC_PLANS_HISTORY);
|
||||
await db.delete().from(tbl).exec();
|
||||
export const clearAllSyncPlanRecords = async (db: InternalDBs) => {
|
||||
await db.syncPlansTbl.clear();
|
||||
};
|
||||
|
||||
export const readAllSyncPlanRecordTexts = async (db: lf.DatabaseConnection) => {
|
||||
const schema = db.getSchema().table(DEFAULT_SYNC_PLANS_HISTORY);
|
||||
|
||||
const records = (await db
|
||||
.select()
|
||||
.from(schema)
|
||||
.orderBy(schema.col("ts"), lf.Order.DESC)
|
||||
.exec()) as SyncPlanRecord[];
|
||||
export const 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.sync_plan);
|
||||
return records.map((x) => x.syncPlan);
|
||||
}
|
||||
};
|
||||
|
||||
+640
-157
@@ -11,50 +11,63 @@ import {
|
||||
TFolder,
|
||||
} from "obsidian";
|
||||
import * as CodeMirror from "codemirror";
|
||||
import {
|
||||
clearAllSyncPlanRecords,
|
||||
clearAllSyncMetaMapping,
|
||||
DatabaseConnection,
|
||||
} from "./localdb";
|
||||
import process from "process";
|
||||
import {
|
||||
prepareDBs,
|
||||
destroyDBs,
|
||||
loadDeleteRenameHistoryTable,
|
||||
clearAllSyncPlanRecords,
|
||||
clearAllSyncMetaMapping,
|
||||
insertDeleteRecord,
|
||||
insertRenameRecord,
|
||||
getAllDeleteRenameRecords,
|
||||
insertSyncPlanRecord,
|
||||
} from "./localdb";
|
||||
import type { InternalDBs } from "./localdb";
|
||||
|
||||
import type { SyncStatusType, PasswordCheckType } from "./sync";
|
||||
import { isPasswordOk, getSyncPlan, doActualSync } from "./sync";
|
||||
import {
|
||||
DEFAULT_S3_CONFIG,
|
||||
getS3Client,
|
||||
listFromRemote,
|
||||
S3Config,
|
||||
checkS3Connectivity,
|
||||
} from "./s3";
|
||||
import { exportSyncPlansToFiles } from "./debugMode";
|
||||
|
||||
interface SaveRemotePluginSettings {
|
||||
s3?: S3Config;
|
||||
password?: string;
|
||||
import { S3Config, DEFAULT_S3_CONFIG } from "./s3";
|
||||
import { WebdavConfig, DEFAULT_WEBDAV_CONFIG, WebdavAuthType } from "./webdav";
|
||||
import {
|
||||
DropboxConfig,
|
||||
DEFAULT_DROPBOX_CONFIG,
|
||||
getCodeVerifierAndChallenge,
|
||||
getAuthUrl,
|
||||
sendAuthReq,
|
||||
setConfigBySuccessfullAuthInplace,
|
||||
} from "./remoteForDropbox";
|
||||
|
||||
import { RemoteClient } from "./remote";
|
||||
import { exportSyncPlansToFiles } from "./debugMode";
|
||||
import { SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
||||
|
||||
interface RemotelySavePluginSettings {
|
||||
s3: S3Config;
|
||||
webdav: WebdavConfig;
|
||||
dropbox: DropboxConfig;
|
||||
password: string;
|
||||
serviceType: SUPPORTED_SERVICES_TYPE;
|
||||
enableExperimentService: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: SaveRemotePluginSettings = {
|
||||
const DEFAULT_SETTINGS: RemotelySavePluginSettings = {
|
||||
s3: DEFAULT_S3_CONFIG,
|
||||
webdav: DEFAULT_WEBDAV_CONFIG,
|
||||
dropbox: DEFAULT_DROPBOX_CONFIG,
|
||||
password: "",
|
||||
serviceType: "s3",
|
||||
enableExperimentService: false,
|
||||
};
|
||||
|
||||
export default class SaveRemotePlugin extends Plugin {
|
||||
settings: SaveRemotePluginSettings;
|
||||
export default class RemotelySavePlugin extends Plugin {
|
||||
settings: RemotelySavePluginSettings;
|
||||
cm: CodeMirror.Editor;
|
||||
db: DatabaseConnection;
|
||||
db: InternalDBs;
|
||||
syncStatus: SyncStatusType;
|
||||
|
||||
async onload() {
|
||||
console.log("loading plugin obsidian-save-remote");
|
||||
console.log("loading plugin obsidian-remotely-save");
|
||||
|
||||
await this.loadSettings();
|
||||
|
||||
@@ -74,27 +87,39 @@ export default class SaveRemotePlugin extends Plugin {
|
||||
})
|
||||
);
|
||||
|
||||
this.addRibbonIcon("switch", "Save Remote", async () => {
|
||||
this.addRibbonIcon("switch", "Remotely Save", async () => {
|
||||
if (this.syncStatus !== "idle") {
|
||||
new Notice(`Save Remote already running in stage ${this.syncStatus}!`);
|
||||
new Notice(
|
||||
`Remotely Save already running in stage ${this.syncStatus}!`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
//console.log(`huh ${this.settings.password}`)
|
||||
new Notice("1/6 Save Remote Sync Preparing");
|
||||
new Notice(
|
||||
`1/6 Remotely Save Sync Preparing (${this.settings.serviceType})`
|
||||
);
|
||||
this.syncStatus = "preparing";
|
||||
|
||||
new Notice("2/6 Starting to fetch remote meta data.");
|
||||
this.syncStatus = "getting_remote_meta";
|
||||
const s3Client = getS3Client(this.settings.s3);
|
||||
const remoteRsp = await listFromRemote(s3Client, this.settings.s3);
|
||||
const self = this;
|
||||
const client = new RemoteClient(
|
||||
this.settings.serviceType,
|
||||
this.settings.s3,
|
||||
this.settings.webdav,
|
||||
this.settings.dropbox,
|
||||
this.app.vault.getName(),
|
||||
() => self.saveSettings()
|
||||
);
|
||||
const remoteRsp = await client.listFromRemote();
|
||||
// console.log(remoteRsp);
|
||||
|
||||
new Notice("3/6 Starting to fetch local meta data.");
|
||||
this.syncStatus = "getting_local_meta";
|
||||
const local = this.app.vault.getAllLoadedFiles();
|
||||
const localHistory = await loadDeleteRenameHistoryTable(this.db);
|
||||
// console.log(remoteRsp);
|
||||
// console.log(local);
|
||||
// console.log(localHistory);
|
||||
|
||||
@@ -116,6 +141,7 @@ export default class SaveRemotePlugin extends Plugin {
|
||||
local,
|
||||
localHistory,
|
||||
this.db,
|
||||
client.serviceType,
|
||||
this.settings.password
|
||||
);
|
||||
console.log(syncPlan.mixedStates); // for debugging
|
||||
@@ -124,23 +150,22 @@ export default class SaveRemotePlugin extends Plugin {
|
||||
// The operations above are read only and kind of safe.
|
||||
// The operations below begins to write or delete (!!!) something.
|
||||
|
||||
new Notice("6/7 Save Remote Sync data exchanging!");
|
||||
new Notice("6/7 Remotely Save Sync data exchanging!");
|
||||
|
||||
this.syncStatus = "syncing";
|
||||
await doActualSync(
|
||||
s3Client,
|
||||
this.settings.s3,
|
||||
client,
|
||||
this.db,
|
||||
this.app.vault,
|
||||
syncPlan,
|
||||
this.settings.password
|
||||
);
|
||||
|
||||
new Notice("7/7 Save Remote finish!");
|
||||
new Notice("7/7 Remotely Save finish!");
|
||||
this.syncStatus = "finish";
|
||||
this.syncStatus = "idle";
|
||||
} catch (error) {
|
||||
const msg = `Save Remote error while ${this.syncStatus}`;
|
||||
const msg = `Remotely Save error while ${this.syncStatus}`;
|
||||
console.log(msg);
|
||||
console.log(error);
|
||||
new Notice(msg);
|
||||
@@ -149,12 +174,12 @@ export default class SaveRemotePlugin extends Plugin {
|
||||
}
|
||||
});
|
||||
|
||||
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.registerCodeMirror((cm: CodeMirror.Editor) => {
|
||||
// this.cm = cm;
|
||||
// console.log("codemirror registered.");
|
||||
// });
|
||||
|
||||
// this.registerDomEvent(document, "click", (evt: MouseEvent) => {
|
||||
// console.log("click", evt);
|
||||
@@ -166,12 +191,19 @@ export default class SaveRemotePlugin extends Plugin {
|
||||
}
|
||||
|
||||
onunload() {
|
||||
console.log("unloading plugin obsidian-save-remote");
|
||||
console.log("unloading plugin obsidian-remotely-save");
|
||||
this.destroyDBs();
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
JSON.parse(JSON.stringify(DEFAULT_SETTINGS)) /* copy an object */,
|
||||
await this.loadData()
|
||||
);
|
||||
if (this.settings.dropbox.clientID === "") {
|
||||
this.settings.dropbox.clientID = DEFAULT_SETTINGS.dropbox.clientID;
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
@@ -188,9 +220,9 @@ export default class SaveRemotePlugin extends Plugin {
|
||||
}
|
||||
|
||||
export class PasswordModal extends Modal {
|
||||
plugin: SaveRemotePlugin;
|
||||
plugin: RemotelySavePlugin;
|
||||
newPassword: string;
|
||||
constructor(app: App, plugin: SaveRemotePlugin, newPassword: string) {
|
||||
constructor(app: App, plugin: RemotelySavePlugin, newPassword: string) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.newPassword = newPassword;
|
||||
@@ -201,18 +233,20 @@ export class PasswordModal extends Modal {
|
||||
// contentEl.setText("Add Or change password.");
|
||||
contentEl.createEl("h2", { text: "Hold on and PLEASE READ ON..." });
|
||||
contentEl.createEl("p", {
|
||||
text: "This password allows you encrypt your files before sending to remote services.",
|
||||
text: "If the field is not empty, files are enctrypted using the password locally before sent to remote.",
|
||||
});
|
||||
contentEl.createEl("p", {
|
||||
text: "If the field is empty, then no password is used, and files would be sent without encryption.",
|
||||
});
|
||||
contentEl.createEl("p", { text: "Empty means no password." });
|
||||
|
||||
contentEl.createEl("p", {
|
||||
text: "Attention 1/4: The password setting itself is stored in PLAIN TEXT LOCALLY (because the plugin needs to use the password to encrypt the files) (and the password would not be sent to remote by this plugin).",
|
||||
text: "Attention 1/4: The password itself is stored in PLAIN TEXT LOCALLY and would not be sent to remote by this plugin.",
|
||||
});
|
||||
contentEl.createEl("p", {
|
||||
text: "Attention 2/4: The file contents are encrypted using openssl format. BUT, some metadata such as file sizes and directory structures are not encrypted or can be easily guessed.",
|
||||
text: "Attention 2/4: Non-empty file contents are encrypted using openssl format. File/directory path are also encrypted then applied base32. BUT, some metadata such as file sizes and directory structures are not encrypted or can be easily guessed, and directory path are stored as 0-byte-size object remotely.",
|
||||
});
|
||||
contentEl.createEl("p", {
|
||||
text: "Attention 3/4: If you change the password. You should make sure the remote service (s3/webdav/...) IS EMPTY, or REMOTE FILES WERE ENCRYPTED BY THAT NEW PASSWORD. OTHERWISE SOMETHING BAD WOULD HAPPEN!",
|
||||
text: "Attention 3/4: Before changing password, you should make sure the remote store (s3/webdav/...) IS EMPTY, or REMOTE FILES WERE ENCRYPTED BY THAT NEW PASSWORD. OTHERWISE SOMETHING BAD WOULD HAPPEN!",
|
||||
});
|
||||
contentEl.createEl("p", {
|
||||
text: "Attention 4/4: The longer the password, the better.",
|
||||
@@ -230,7 +264,7 @@ export class PasswordModal extends Modal {
|
||||
button.setClass("password_second_confirm");
|
||||
})
|
||||
.addButton((button) => {
|
||||
button.setButtonText("Cancel (password not changed.)");
|
||||
button.setButtonText("Go Back");
|
||||
button.onClick(() => {
|
||||
this.close();
|
||||
});
|
||||
@@ -243,10 +277,116 @@ export class PasswordModal extends Modal {
|
||||
}
|
||||
}
|
||||
|
||||
class SaveRemoteSettingTab extends PluginSettingTab {
|
||||
plugin: SaveRemotePlugin;
|
||||
export 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;
|
||||
}
|
||||
|
||||
constructor(app: App, plugin: SaveRemotePlugin) {
|
||||
onOpen() {
|
||||
let { contentEl } = this;
|
||||
|
||||
const k = getCodeVerifierAndChallenge();
|
||||
const authUrl = getAuthUrl(
|
||||
this.plugin.settings.dropbox.clientID,
|
||||
k.challenge
|
||||
);
|
||||
|
||||
contentEl.createEl("p", {
|
||||
text: "Step 1: Visit the following address in a browser, and follow the steps on the web page to authorize.",
|
||||
});
|
||||
contentEl.createEl("p").createEl("a", {
|
||||
href: authUrl,
|
||||
text: authUrl,
|
||||
});
|
||||
|
||||
contentEl.createEl("p", {
|
||||
text: 'Step 2: In the end of the web flow, you obtain a long code. Paste it here then click "Submit".',
|
||||
});
|
||||
|
||||
let authCode = "";
|
||||
new Setting(contentEl)
|
||||
.setName("Auth Code from web page")
|
||||
.setDesc('You need to click "Confirm".')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue("")
|
||||
.onChange((val) => {
|
||||
authCode = val.trim();
|
||||
})
|
||||
)
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText("Confirm");
|
||||
button.onClick(async () => {
|
||||
new Notice("Trying to connect to Dropbox");
|
||||
try {
|
||||
const authRes = await sendAuthReq(
|
||||
this.plugin.settings.dropbox.clientID,
|
||||
k.verifier,
|
||||
authCode
|
||||
);
|
||||
const self = this;
|
||||
setConfigBySuccessfullAuthInplace(
|
||||
this.plugin.settings.dropbox,
|
||||
authRes,
|
||||
() => self.plugin.saveSettings()
|
||||
);
|
||||
const client = new RemoteClient(
|
||||
"dropbox",
|
||||
undefined,
|
||||
undefined,
|
||||
this.plugin.settings.dropbox,
|
||||
this.app.vault.getName(),
|
||||
() => self.plugin.saveSettings()
|
||||
);
|
||||
const username = await client.getUser();
|
||||
this.plugin.settings.dropbox.username = username;
|
||||
await this.plugin.saveSettings();
|
||||
new Notice(`Good! We've connected to Dropbox as user ${username}!`);
|
||||
this.authDiv.toggleClass(
|
||||
"dropbox-auth-button-hide",
|
||||
this.plugin.settings.dropbox.username !== ""
|
||||
);
|
||||
this.revokeAuthDiv.toggleClass(
|
||||
"dropbox-revoke-auth-button-hide",
|
||||
this.plugin.settings.dropbox.username === ""
|
||||
);
|
||||
this.revokeAuthSetting.setDesc(
|
||||
`You've connected as user ${this.plugin.settings.dropbox.username}. If you want to disconnect, click this button`
|
||||
);
|
||||
this.close();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
new Notice("Something goes wrong while connecting to Dropbox.");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onClose() {
|
||||
let { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
class RemotelySaveSettingTab extends PluginSettingTab {
|
||||
plugin: RemotelySavePlugin;
|
||||
|
||||
constructor(app: App, plugin: RemotelySavePlugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
@@ -256,99 +396,14 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
||||
|
||||
containerEl.empty();
|
||||
|
||||
containerEl.createEl("h1", { text: "Save Remote" });
|
||||
containerEl.createEl("h1", { text: "Remotely Save" });
|
||||
|
||||
containerEl.createEl("h2", { text: "S3" });
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("s3Endpoint")
|
||||
.setDesc("s3Endpoint")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue(this.plugin.settings.s3.s3Endpoint)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.s3.s3Endpoint = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("s3Region")
|
||||
.setDesc("s3Region")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue(`${this.plugin.settings.s3.s3Region}`)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.s3.s3Region = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("s3AccessKeyID")
|
||||
.setDesc("s3AccessKeyID")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue(`${this.plugin.settings.s3.s3AccessKeyID}`)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.s3.s3AccessKeyID = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("s3SecretAccessKey")
|
||||
.setDesc("s3SecretAccessKey")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue(`${this.plugin.settings.s3.s3SecretAccessKey}`)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.s3.s3SecretAccessKey = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("s3BucketName")
|
||||
.setDesc("s3BucketName")
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder("")
|
||||
.setValue(`${this.plugin.settings.s3.s3BucketName}`)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.s3.s3BucketName = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("check connectivity")
|
||||
.setDesc("check connectivity")
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText("Check");
|
||||
button.onClick(async () => {
|
||||
new Notice("Checking...");
|
||||
const s3Client = getS3Client(this.plugin.settings.s3);
|
||||
const res = await checkS3Connectivity(
|
||||
s3Client,
|
||||
this.plugin.settings.s3
|
||||
);
|
||||
if (res) {
|
||||
new Notice("Great! The bucket can be accessed.");
|
||||
} else {
|
||||
new Notice("The S3 bucket cannot be reached.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
containerEl.createEl("h2", { text: "General" });
|
||||
const generalDiv = containerEl.createEl("div");
|
||||
generalDiv.createEl("h2", { text: "General" });
|
||||
|
||||
const passwordDiv = generalDiv.createEl("div");
|
||||
let newPassword = `${this.plugin.settings.password}`;
|
||||
new Setting(containerEl)
|
||||
new Setting(passwordDiv)
|
||||
.setName("encryption password")
|
||||
.setDesc(
|
||||
'Password for E2E encryption. Empty for no password. You need to click "Confirm".'
|
||||
@@ -368,17 +423,432 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
||||
});
|
||||
});
|
||||
|
||||
containerEl.createEl("h2", { text: "Debug" });
|
||||
// we need to create the div in advance of any other service divs
|
||||
const serviceChooserDiv = generalDiv.createEl("div");
|
||||
|
||||
const syncPlanDiv = containerEl.createEl("div");
|
||||
syncPlanDiv.createEl("p", {
|
||||
text: "Sync plans are created every time after you trigger sync and before the actual sync.",
|
||||
});
|
||||
syncPlanDiv.createEl("p", {
|
||||
text: "They are useful to know what would actually happen in those sync.",
|
||||
let clickChooserTimes = 0;
|
||||
serviceChooserDiv.onClickEvent(async (x) => {
|
||||
if (Platform.isIosApp) {
|
||||
// downgrade the experiment
|
||||
// because iOS doesn't support x.detail
|
||||
clickChooserTimes += 1;
|
||||
setTimeout(function () {
|
||||
clickChooserTimes = 0;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
if ((Platform.isIosApp && clickChooserTimes === 5) || x.detail === 5) {
|
||||
if (this.plugin.settings.serviceType === "webdav") {
|
||||
new Notice(
|
||||
"You've enabled hidden unstable experimental webdav support before. Nothing changes."
|
||||
);
|
||||
} else if (!this.plugin.settings.enableExperimentService) {
|
||||
this.plugin.settings.enableExperimentService = true;
|
||||
await this.plugin.saveSettings();
|
||||
new Notice(
|
||||
"You've enabled hidden unstable experimental webdav support. Reopen settings again and try webdav with caution."
|
||||
);
|
||||
} else if (this.plugin.settings.enableExperimentService) {
|
||||
this.plugin.settings.enableExperimentService = false;
|
||||
await this.plugin.saveSettings();
|
||||
new Notice(
|
||||
"You've disabled hidden unstable experimental webdav support. Reopen settings again."
|
||||
);
|
||||
}
|
||||
}
|
||||
x.preventDefault();
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
const s3Div = containerEl.createEl("div", { cls: "s3-hide" });
|
||||
s3Div.toggleClass("s3-hide", this.plugin.settings.serviceType !== "s3");
|
||||
s3Div.createEl("h2", { text: "S3 (-compatible) Service" });
|
||||
|
||||
s3Div.createEl("p", {
|
||||
text: "You can use Amazon S3 or another S3-compatible service to sync your vault. Enter your bucket information below.",
|
||||
});
|
||||
|
||||
s3Div.createEl("p", {
|
||||
text: "Disclaimer: The infomation is stored in PLAIN TEXT locally. Other malicious/harmful/faulty plugins may or may not be able to read the info. If you see any unintentional access to your S3 bucket, please immediately delete the access key to stop further accessment.",
|
||||
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:",
|
||||
});
|
||||
|
||||
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,
|
||||
undefined
|
||||
);
|
||||
const res = await client.checkConnectivity();
|
||||
if (res) {
|
||||
new Notice("Great! The bucket can be accessed.");
|
||||
} else {
|
||||
new Notice("The S3 bucket cannot be reached.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const dropboxDiv = containerEl.createEl("div", { cls: "dropbox-hide" });
|
||||
dropboxDiv.toggleClass(
|
||||
"dropbox-hide",
|
||||
this.plugin.settings.serviceType !== "dropbox"
|
||||
);
|
||||
dropboxDiv.createEl("h2", { text: "for Dropbox" });
|
||||
dropboxDiv.createEl("p", {
|
||||
text: "Disclaimer: Sync support for Dropbox are more experimental, and s3 functions are more stable now.",
|
||||
cls: "dropbox-disclaimer",
|
||||
});
|
||||
dropboxDiv.createEl("p", {
|
||||
text: "Disclaimer: This app is NOT an official Dropbox product. It just uses Dropbox open api.",
|
||||
cls: "dropbox-disclaimer",
|
||||
});
|
||||
dropboxDiv.createEl("p", {
|
||||
text: "Disclaimer: The infomation is stored in PLAIN TEXT locally. Other malicious/harmful/faulty plugins may or may not be able to 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 a folder App/obsidian-remotely-save on your Dropbox. All files/folders sync would happen inside this folder.",
|
||||
});
|
||||
|
||||
const dropboxSelectAuthDiv = dropboxDiv.createDiv();
|
||||
const dropboxAuthDiv = dropboxSelectAuthDiv.createDiv({
|
||||
cls: "dropbox-auth-button-hide",
|
||||
});
|
||||
const dropboxRevokeAuthDiv = dropboxSelectAuthDiv.createDiv({
|
||||
cls: "dropbox-revoke-auth-button-hide",
|
||||
});
|
||||
|
||||
const revokeAuthSetting = 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,
|
||||
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 () => {
|
||||
new DropboxAuthModal(
|
||||
this.app,
|
||||
this.plugin,
|
||||
dropboxAuthDiv,
|
||||
dropboxRevokeAuthDiv,
|
||||
revokeAuthSetting
|
||||
).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,
|
||||
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.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const webdavDiv = containerEl.createEl("div", { cls: "webdav-hide" });
|
||||
webdavDiv.toggleClass(
|
||||
"webdav-hide",
|
||||
this.plugin.settings.serviceType !== "webdav"
|
||||
);
|
||||
|
||||
webdavDiv.createEl("h2", { text: "Webdav Service" });
|
||||
|
||||
webdavDiv.createEl("p", {
|
||||
text: "Disclaimer: Webdav functions are more experimental, and s3 functions are more stable now.",
|
||||
cls: "webdav-disclaimer",
|
||||
});
|
||||
|
||||
webdavDiv.createEl("p", {
|
||||
text: "Disclaimer: The infomation is stored in PLAIN TEXT locally. Other malicious/harmful/faulty plugins may or may not be able to read the info. If you see any unintentional access to your webdav server, please immediately change the username and/or password to stop further accessment.",
|
||||
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",
|
||||
});
|
||||
|
||||
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(
|
||||
"Server auth type. If you do not set 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
|
||||
);
|
||||
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.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// we need to create chooser
|
||||
// after s3Div and webdavDiv being created
|
||||
new Setting(serviceChooserDiv)
|
||||
.setName("Choose service")
|
||||
.setDesc("Choose a service, by default s3")
|
||||
.addDropdown(async (dropdown) => {
|
||||
const currService = this.plugin.settings.serviceType;
|
||||
const enableExperimentService =
|
||||
this.plugin.settings.enableExperimentService;
|
||||
|
||||
dropdown.addOption("s3", "s3 (-compatible)");
|
||||
dropdown.addOption("dropbox", "Dropbox");
|
||||
if (currService === "webdav" || enableExperimentService) {
|
||||
dropdown.addOption("webdav", "webdav (experimental)");
|
||||
if (!enableExperimentService) {
|
||||
this.plugin.settings.enableExperimentService = true;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
}
|
||||
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"
|
||||
);
|
||||
webdavDiv.toggleClass(
|
||||
"webdav-hide",
|
||||
this.plugin.settings.serviceType !== "webdav"
|
||||
);
|
||||
await this.plugin.saveSettings();
|
||||
});
|
||||
});
|
||||
|
||||
const debugDiv = containerEl.createEl("div");
|
||||
debugDiv.createEl("h2", { text: "Debug" });
|
||||
const syncPlanDiv = debugDiv.createEl("div");
|
||||
syncPlanDiv.createEl("p", {
|
||||
text: "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.",
|
||||
});
|
||||
|
||||
new Setting(syncPlanDiv)
|
||||
.setName("export sync plans")
|
||||
.setDesc("export sync plans")
|
||||
.addButton(async (button) => {
|
||||
@@ -389,7 +859,7 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
||||
});
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
new Setting(syncPlanDiv)
|
||||
.setName("delete sync plans history in db")
|
||||
.setDesc("delete sync plans history in db")
|
||||
.addButton(async (button) => {
|
||||
@@ -400,16 +870,12 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
||||
});
|
||||
});
|
||||
|
||||
const syncMappingDiv = containerEl.createEl("div");
|
||||
const syncMappingDiv = debugDiv.createEl("div");
|
||||
syncMappingDiv.createEl("p", {
|
||||
text: "Sync mappings history stores the actual LOCAL last modified time of the REMOTE objects.",
|
||||
text: "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.",
|
||||
});
|
||||
|
||||
syncMappingDiv.createEl("p", {
|
||||
text: "If the sync mappings history are deleted, unnecessary data exchanges may occur in next-time syncing, because whether a remote object and local object with same name are equivalent or not could not be determined correctly by comparing last modified times.",
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
new Setting(syncMappingDiv)
|
||||
.setName("delete sync mappings history in db")
|
||||
.setDesc("delete sync mappings history in db")
|
||||
.addButton(async (button) => {
|
||||
@@ -419,5 +885,22 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
||||
new Notice("sync mappings history (in local db) deleted");
|
||||
});
|
||||
});
|
||||
|
||||
const dbsResetDiv = debugDiv.createEl("div");
|
||||
syncMappingDiv.createEl("p", {
|
||||
text: "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.",
|
||||
});
|
||||
new Setting(syncMappingDiv)
|
||||
.setName("reset local internal cache/databases")
|
||||
.setDesc("reset local internal cache/databases")
|
||||
.addButton(async (button) => {
|
||||
button.setButtonText("Reset");
|
||||
button.onClick(async () => {
|
||||
await destroyDBs();
|
||||
new Notice(
|
||||
"Local internal cache/databases deleted. Please manually reload the plugin."
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+28
-3
@@ -4,8 +4,6 @@ import * as path from "path";
|
||||
import { base32 } from "rfc4648";
|
||||
import XRegExp from "xregexp";
|
||||
|
||||
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "ftp";
|
||||
|
||||
/**
|
||||
* If any part of the file starts with '.' or '_' then it's a hidden file.
|
||||
* @param item
|
||||
@@ -47,7 +45,10 @@ export const getFolderLevels = (x: string) => {
|
||||
const y1 = x.split("/");
|
||||
let i = 0;
|
||||
for (let index = 0; index + 1 < y1.length; index++) {
|
||||
res.push(y1.slice(0, index + 1).join("/"));
|
||||
const k = y1.slice(0, index + 1).join("/");
|
||||
if (k !== "" && k !== "/") {
|
||||
res.push(k);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
@@ -130,3 +131,27 @@ export const isVaildText = (a: string) => {
|
||||
a
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* If input is already a folder, returns it as is;
|
||||
* And if input is a file, returns its direname.
|
||||
* @param a
|
||||
* @returns
|
||||
*/
|
||||
export const getPathFolder = (a: string) => {
|
||||
if (a.endsWith("/")) {
|
||||
return a;
|
||||
}
|
||||
const b = path.posix.dirname(a);
|
||||
return b.endsWith("/") ? b : `${b}/`;
|
||||
};
|
||||
|
||||
/**
|
||||
* https://stackoverflow.com/questions/54511144
|
||||
* @param a
|
||||
* @param delimiter
|
||||
* @returns
|
||||
*/
|
||||
export const setToString = (a: Set<string>, delimiter: string = ",") => {
|
||||
return [...a].join(delimiter);
|
||||
};
|
||||
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
import { Vault } from "obsidian";
|
||||
|
||||
import type { SUPPORTED_SERVICES_TYPE } from "./baseTypes";
|
||||
import * as s3 from "./s3";
|
||||
import * as webdav from "./webdav";
|
||||
import * as dropbox from "./remoteForDropbox";
|
||||
|
||||
export class RemoteClient {
|
||||
readonly serviceType: SUPPORTED_SERVICES_TYPE;
|
||||
readonly s3Client?: s3.S3Client;
|
||||
readonly s3Config?: s3.S3Config;
|
||||
readonly webdavClient?: webdav.WebDAVClient;
|
||||
readonly webdavConfig?: webdav.WebdavConfig;
|
||||
readonly dropboxClient?: dropbox.WrappedDropboxClient;
|
||||
readonly dropboxConfig?: dropbox.DropboxConfig;
|
||||
|
||||
constructor(
|
||||
serviceType: SUPPORTED_SERVICES_TYPE,
|
||||
s3Config?: s3.S3Config,
|
||||
webdavConfig?: webdav.WebdavConfig,
|
||||
dropboxConfig?: dropbox.DropboxConfig,
|
||||
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") {
|
||||
this.webdavConfig = webdavConfig;
|
||||
this.webdavClient = webdav.getWebdavClient(this.webdavConfig);
|
||||
} 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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
throw Error(`not supported service type ${this.serviceType}`);
|
||||
}
|
||||
};
|
||||
|
||||
getUser = async () => {
|
||||
if (this.serviceType === "dropbox") {
|
||||
return await dropbox.getUserDisplayName(this.dropboxClient);
|
||||
} 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,615 @@
|
||||
import * as path from "path";
|
||||
import { FileStats, Vault } from "obsidian";
|
||||
import { Buffer } from "buffer";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
import { Dropbox, DropboxResponse, files } from "dropbox";
|
||||
export { Dropbox } from "dropbox";
|
||||
import { RemoteItem } from "./baseTypes";
|
||||
import {
|
||||
arrayBufferToBuffer,
|
||||
bufferToArrayBuffer,
|
||||
mkdirpInVault,
|
||||
getPathFolder,
|
||||
getFolderLevels,
|
||||
setToString,
|
||||
} from "./misc";
|
||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
||||
|
||||
export interface DropboxConfig {
|
||||
accessToken: string;
|
||||
clientID: string;
|
||||
refreshToken: string;
|
||||
accessTokenExpiresInSeconds: number;
|
||||
accessTokenExpiresAtTime: number;
|
||||
accountID: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_DROPBOX_CONFIG = {
|
||||
accessToken: "",
|
||||
clientID: process.env.DEFAULT_DROPBOX_APP_KEY,
|
||||
refreshToken: "",
|
||||
accessTokenExpiresInSeconds: 0,
|
||||
accessTokenExpiresAtTime: 0,
|
||||
accountID: "",
|
||||
username: "",
|
||||
};
|
||||
|
||||
export const getDropboxPath = (fileOrFolderPath: string, vaultName: string) => {
|
||||
let key = fileOrFolderPath;
|
||||
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-
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const specialBase64Encode = (str: Buffer) => {
|
||||
return str
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
};
|
||||
const sha256 = (buffer: string) => {
|
||||
return crypto.createHash("sha256").update(buffer).digest();
|
||||
};
|
||||
|
||||
export const getCodeVerifierAndChallenge = () => {
|
||||
const codeVerifier = specialBase64Encode(crypto.randomBytes(32));
|
||||
// console.log(`Client generated code_verifier: ${codeVerifier}`);
|
||||
const codeChallenge = specialBase64Encode(sha256(codeVerifier));
|
||||
// console.log(`Client generated code_challenge: ${codeChallenge}`);
|
||||
return {
|
||||
verifier: codeVerifier,
|
||||
challenge: codeChallenge,
|
||||
};
|
||||
};
|
||||
|
||||
export const getAuthUrl = (appKey: string, challenge: string) => {
|
||||
return `https://www.dropbox.com/oauth2/authorize?client_id=${appKey}&response_type=code&code_challenge=${challenge}&code_challenge_method=S256&token_access_type=offline`;
|
||||
};
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
const resp2 = (await resp1.json()) as DropboxSuccessAuthRes;
|
||||
return resp2;
|
||||
};
|
||||
|
||||
export const sendRefreshTokenReq = async (
|
||||
appKey: string,
|
||||
refreshToken: string
|
||||
) => {
|
||||
console.log("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;
|
||||
console.log("finish auto getting refreshed Dropbox access token.");
|
||||
return resp2;
|
||||
};
|
||||
|
||||
export const setConfigBySuccessfullAuthInplace = async (
|
||||
config: DropboxConfig,
|
||||
authRes: DropboxSuccessAuthRes,
|
||||
saveUpdatedConfigFunc: () => Promise<any> | undefined
|
||||
) => {
|
||||
console.log("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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
console.log("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
|
||||
// console.log(`checking remote has folder /${this.vaultName}`);
|
||||
if (this.vaultFolderExists) {
|
||||
// console.log(`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) {
|
||||
console.log(`remote does not have folder /${this.vaultName}`);
|
||||
await this.dropbox.filesCreateFolderV2({
|
||||
path: `/${this.vaultName}`,
|
||||
});
|
||||
console.log(`remote folder /${this.vaultName} created`);
|
||||
} else {
|
||||
// console.log(`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();
|
||||
const res = await client.dropbox.filesListFolder({
|
||||
path: `/${client.vaultName}`,
|
||||
recursive: true,
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
throw Error(JSON.stringify(res));
|
||||
}
|
||||
// console.log(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));
|
||||
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 = ""
|
||||
) => {
|
||||
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();
|
||||
};
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
HeadBucketCommand,
|
||||
ListObjectsV2CommandInput,
|
||||
ListObjectsV2CommandOutput,
|
||||
HeadObjectCommandOutput,
|
||||
} from "@aws-sdk/client-s3";
|
||||
export { S3Client } from "@aws-sdk/client-s3";
|
||||
|
||||
import type { _Object } from "@aws-sdk/client-s3";
|
||||
|
||||
@@ -24,6 +26,8 @@ import {
|
||||
mkdirpInVault,
|
||||
} from "./misc";
|
||||
import * as mime from "mime-types";
|
||||
|
||||
import { RemoteItem } from "./baseTypes";
|
||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
||||
|
||||
export interface S3Config {
|
||||
@@ -44,10 +48,37 @@ export const DEFAULT_S3_CONFIG = {
|
||||
|
||||
export type S3ObjectType = _Object;
|
||||
|
||||
const fromS3ObjectToRemoteItem = (x: S3ObjectType) => {
|
||||
return {
|
||||
key: x.Key,
|
||||
lastModified: x.LastModified.valueOf(),
|
||||
size: x.Size,
|
||||
remoteType: "s3",
|
||||
etag: x.ETag,
|
||||
} as RemoteItem;
|
||||
};
|
||||
|
||||
const fromS3HeadObjectToRemoteItem = (
|
||||
key: string,
|
||||
x: HeadObjectCommandOutput
|
||||
) => {
|
||||
return {
|
||||
key: key,
|
||||
lastModified: x.LastModified.valueOf(),
|
||||
size: x.ContentLength,
|
||||
remoteType: "s3",
|
||||
etag: x.ETag,
|
||||
} as RemoteItem;
|
||||
};
|
||||
|
||||
export const getS3Client = (s3Config: S3Config) => {
|
||||
let endpoint = s3Config.s3Endpoint;
|
||||
if (!(endpoint.startsWith("http://") || endpoint.startsWith("https://"))) {
|
||||
endpoint = `https://${endpoint}`;
|
||||
}
|
||||
const s3Client = new S3Client({
|
||||
region: s3Config.s3Region,
|
||||
endpoint: s3Config.s3Endpoint,
|
||||
endpoint: endpoint,
|
||||
credentials: {
|
||||
accessKeyId: s3Config.s3AccessKeyID,
|
||||
secretAccessKey: s3Config.s3SecretAccessKey,
|
||||
@@ -61,12 +92,14 @@ export const getRemoteMeta = async (
|
||||
s3Config: S3Config,
|
||||
fileOrFolderPath: string
|
||||
) => {
|
||||
return await s3Client.send(
|
||||
const res = await s3Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: s3Config.s3BucketName,
|
||||
Key: fileOrFolderPath,
|
||||
})
|
||||
);
|
||||
|
||||
return fromS3HeadObjectToRemoteItem(fileOrFolderPath, res);
|
||||
};
|
||||
|
||||
export const uploadToRemote = async (
|
||||
@@ -177,10 +210,7 @@ export const listFromRemote = async (
|
||||
|
||||
// ensemble fake rsp
|
||||
return {
|
||||
"$.metadata": {
|
||||
httpStatusCode: 200,
|
||||
},
|
||||
Contents: contents,
|
||||
Contents: contents.map((x) => fromS3ObjectToRemoteItem(x)),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -195,12 +225,12 @@ const getObjectBodyToArrayBuffer = async (
|
||||
b: Readable | ReadableStream | Blob
|
||||
) => {
|
||||
if (b instanceof Readable) {
|
||||
return (await new Promise((resolve, reject) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
for await (let chunk of b) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const buf = Buffer.concat(chunks);
|
||||
return bufferToArrayBuffer(buf);
|
||||
b.on("data", (chunk) => chunks.push(chunk));
|
||||
b.on("error", reject);
|
||||
b.on("end", () => resolve(bufferToArrayBuffer(Buffer.concat(chunks))));
|
||||
})) as ArrayBuffer;
|
||||
} else if (b instanceof ReadableStream) {
|
||||
return await new Response(b, {}).arrayBuffer();
|
||||
} else if (b instanceof Blob) {
|
||||
@@ -210,7 +240,7 @@ const getObjectBodyToArrayBuffer = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadFromRemoteRaw = async (
|
||||
const downloadFromRemoteRaw = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config,
|
||||
fileOrFolderPath: string
|
||||
@@ -298,7 +328,7 @@ export const deleteFromRemote = async (
|
||||
await s3Client.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: s3Config.s3BucketName,
|
||||
Key: element.Key,
|
||||
Key: element.key,
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -316,7 +346,7 @@ export const deleteFromRemote = async (
|
||||
* @param s3Config
|
||||
* @returns
|
||||
*/
|
||||
export const checkS3Connectivity = async (
|
||||
export const checkConnectivity = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config
|
||||
) => {
|
||||
|
||||
+100
-81
@@ -1,27 +1,15 @@
|
||||
import { TAbstractFile, TFolder, TFile, Vault } from "obsidian";
|
||||
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import * as lf from "lovefield-ts/dist/es6/lf.js";
|
||||
|
||||
import {
|
||||
clearDeleteRenameHistoryOfKey,
|
||||
FileFolderHistoryRecord,
|
||||
upsertSyncMetaMappingDataS3,
|
||||
getSyncMetaMappingByRemoteKeyS3,
|
||||
upsertSyncMetaMappingData,
|
||||
getSyncMetaMappingByRemoteKey,
|
||||
} from "./localdb";
|
||||
import {
|
||||
S3Config,
|
||||
S3ObjectType,
|
||||
uploadToRemote,
|
||||
deleteFromRemote,
|
||||
downloadFromRemote,
|
||||
} from "./s3";
|
||||
import {
|
||||
mkdirpInVault,
|
||||
SUPPORTED_SERVICES_TYPE,
|
||||
isHiddenPath,
|
||||
isVaildText,
|
||||
} from "./misc";
|
||||
import type { FileFolderHistoryRecord, InternalDBs } from "./localdb";
|
||||
|
||||
import { RemoteClient } from "./remote";
|
||||
import type { SUPPORTED_SERVICES_TYPE, RemoteItem } from "./baseTypes";
|
||||
import { mkdirpInVault, isHiddenPath, isVaildText, setToString } from "./misc";
|
||||
import {
|
||||
decryptBase32ToString,
|
||||
encryptStringToBase32,
|
||||
@@ -85,7 +73,7 @@ export interface PasswordCheckType {
|
||||
}
|
||||
|
||||
export const isPasswordOk = async (
|
||||
remote: S3ObjectType[],
|
||||
remote: RemoteItem[],
|
||||
password: string = ""
|
||||
) => {
|
||||
if (remote === undefined || remote.length === 0) {
|
||||
@@ -95,7 +83,7 @@ export const isPasswordOk = async (
|
||||
reason: "empty_remote",
|
||||
} as PasswordCheckType;
|
||||
}
|
||||
const santyCheckKey = remote[0].Key;
|
||||
const santyCheckKey = remote[0].key;
|
||||
if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) {
|
||||
// this is encrypted!
|
||||
// try to decrypt it using the provided password.
|
||||
@@ -143,44 +131,46 @@ export const isPasswordOk = async (
|
||||
};
|
||||
|
||||
const ensembleMixedStates = async (
|
||||
remote: S3ObjectType[],
|
||||
remote: RemoteItem[],
|
||||
local: TAbstractFile[],
|
||||
deleteHistory: FileFolderHistoryRecord[],
|
||||
db: lf.DatabaseConnection,
|
||||
db: InternalDBs,
|
||||
remoteType: SUPPORTED_SERVICES_TYPE,
|
||||
password: string = ""
|
||||
) => {
|
||||
const results = {} as Record<string, FileOrFolderMixedState>;
|
||||
|
||||
if (remote !== undefined) {
|
||||
for (const entry of remote) {
|
||||
const remoteEncryptedKey = entry.Key;
|
||||
const remoteEncryptedKey = entry.key;
|
||||
let key = remoteEncryptedKey;
|
||||
if (password !== "") {
|
||||
key = await decryptBase32ToString(remoteEncryptedKey, password);
|
||||
}
|
||||
const backwardMapping = await getSyncMetaMappingByRemoteKeyS3(
|
||||
const backwardMapping = await getSyncMetaMappingByRemoteKey(
|
||||
remoteType,
|
||||
db,
|
||||
key,
|
||||
entry.LastModified.valueOf(),
|
||||
entry.ETag
|
||||
entry.lastModified,
|
||||
entry.etag
|
||||
);
|
||||
|
||||
let r = {} as FileOrFolderMixedState;
|
||||
if (backwardMapping !== undefined) {
|
||||
key = backwardMapping.local_key;
|
||||
key = backwardMapping.localKey;
|
||||
r = {
|
||||
key: key,
|
||||
exist_remote: true,
|
||||
mtime_remote: backwardMapping.local_mtime,
|
||||
size_remote: backwardMapping.local_size,
|
||||
mtime_remote: backwardMapping.localMtime || entry.lastModified,
|
||||
size_remote: backwardMapping.localSize || entry.size,
|
||||
remote_encrypted_key: remoteEncryptedKey,
|
||||
};
|
||||
} else {
|
||||
r = {
|
||||
key: key,
|
||||
exist_remote: true,
|
||||
mtime_remote: entry.LastModified.valueOf(),
|
||||
size_remote: entry.Size,
|
||||
mtime_remote: entry.lastModified,
|
||||
size_remote: entry.size,
|
||||
remote_encrypted_key: remoteEncryptedKey,
|
||||
};
|
||||
}
|
||||
@@ -240,11 +230,11 @@ const ensembleMixedStates = async (
|
||||
|
||||
for (const entry of deleteHistory) {
|
||||
let key = entry.key;
|
||||
if (entry.key_type === "folder") {
|
||||
if (entry.keyType === "folder") {
|
||||
if (!entry.key.endsWith("/")) {
|
||||
key = `${entry.key}/`;
|
||||
}
|
||||
} else if (entry.key_type === "file") {
|
||||
} else if (entry.keyType === "file") {
|
||||
// pass
|
||||
} else {
|
||||
throw Error(`unexpected ${entry}`);
|
||||
@@ -252,7 +242,7 @@ const ensembleMixedStates = async (
|
||||
|
||||
const r = {
|
||||
key: key,
|
||||
delete_time_local: entry.action_when,
|
||||
delete_time_local: entry.actionWhen,
|
||||
} as FileOrFolderMixedState;
|
||||
|
||||
if (isHiddenPath(key)) {
|
||||
@@ -398,14 +388,19 @@ const getOperation = (
|
||||
r.decision_branch = 11;
|
||||
}
|
||||
|
||||
if (r.decision === "unknown") {
|
||||
throw Error(`unknown decision for ${JSON.stringify(r)}`);
|
||||
}
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
export const getSyncPlan = async (
|
||||
remote: S3ObjectType[],
|
||||
remote: RemoteItem[],
|
||||
local: TAbstractFile[],
|
||||
deleteHistory: FileFolderHistoryRecord[],
|
||||
db: lf.DatabaseConnection,
|
||||
db: InternalDBs,
|
||||
remoteType: SUPPORTED_SERVICES_TYPE,
|
||||
password: string = ""
|
||||
) => {
|
||||
const mixedStates = await ensembleMixedStates(
|
||||
@@ -413,6 +408,7 @@ export const getSyncPlan = async (
|
||||
local,
|
||||
deleteHistory,
|
||||
db,
|
||||
remoteType,
|
||||
password
|
||||
);
|
||||
for (const [key, val] of Object.entries(mixedStates)) {
|
||||
@@ -420,27 +416,21 @@ export const getSyncPlan = async (
|
||||
}
|
||||
const plan = {
|
||||
ts: Date.now(),
|
||||
remoteType: "s3",
|
||||
remoteType: remoteType,
|
||||
mixedStates: mixedStates,
|
||||
} as SyncPlanType;
|
||||
return plan;
|
||||
};
|
||||
|
||||
export const doActualSync = async (
|
||||
s3Client: S3Client,
|
||||
s3Config: S3Config,
|
||||
db: lf.DatabaseConnection,
|
||||
const dispatchOperationToActual = async (
|
||||
key: string,
|
||||
state: FileOrFolderMixedState,
|
||||
client: RemoteClient,
|
||||
db: InternalDBs,
|
||||
vault: Vault,
|
||||
syncPlan: SyncPlanType,
|
||||
password: string = ""
|
||||
password: string = "",
|
||||
foldersCreatedBefore: Set<string> | undefined = undefined
|
||||
) => {
|
||||
const keyStates = syncPlan.mixedStates;
|
||||
await Promise.all(
|
||||
Object.entries(keyStates)
|
||||
.sort((k, v) => -(k as string).length)
|
||||
.map(async ([k, v]) => {
|
||||
const key = k as string;
|
||||
const state = v as FileOrFolderMixedState;
|
||||
let remoteEncryptedKey = key;
|
||||
if (password !== "") {
|
||||
remoteEncryptedKey = state.remote_encrypted_key;
|
||||
@@ -458,9 +448,7 @@ export const doActualSync = async (
|
||||
} else if (state.decision === "skip") {
|
||||
// do nothing
|
||||
} else if (state.decision === "download_clearhist") {
|
||||
await downloadFromRemote(
|
||||
s3Client,
|
||||
s3Config,
|
||||
await client.downloadFromRemote(
|
||||
state.key,
|
||||
vault,
|
||||
state.mtime_remote,
|
||||
@@ -469,31 +457,29 @@ export const doActualSync = async (
|
||||
);
|
||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||
} else if (state.decision === "upload_clearhist") {
|
||||
const remoteObjMeta = await uploadToRemote(
|
||||
s3Client,
|
||||
s3Config,
|
||||
const remoteObjMeta = await client.uploadToRemote(
|
||||
state.key,
|
||||
vault,
|
||||
false,
|
||||
password,
|
||||
remoteEncryptedKey
|
||||
remoteEncryptedKey,
|
||||
foldersCreatedBefore
|
||||
);
|
||||
await upsertSyncMetaMappingDataS3(
|
||||
await upsertSyncMetaMappingData(
|
||||
client.serviceType,
|
||||
db,
|
||||
state.key,
|
||||
state.mtime_local,
|
||||
state.size_local,
|
||||
state.key,
|
||||
remoteObjMeta.LastModified.valueOf(),
|
||||
remoteObjMeta.ContentLength,
|
||||
remoteObjMeta.ETag
|
||||
remoteObjMeta.lastModified,
|
||||
remoteObjMeta.size,
|
||||
remoteObjMeta.etag
|
||||
);
|
||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||
} else if (state.decision === "download") {
|
||||
await mkdirpInVault(state.key, vault);
|
||||
await downloadFromRemote(
|
||||
s3Client,
|
||||
s3Config,
|
||||
await client.downloadFromRemote(
|
||||
state.key,
|
||||
vault,
|
||||
state.mtime_remote,
|
||||
@@ -501,39 +487,72 @@ export const doActualSync = async (
|
||||
remoteEncryptedKey
|
||||
);
|
||||
} else if (state.decision === "delremote_clearhist") {
|
||||
await deleteFromRemote(
|
||||
s3Client,
|
||||
s3Config,
|
||||
state.key,
|
||||
password,
|
||||
remoteEncryptedKey
|
||||
);
|
||||
await client.deleteFromRemote(state.key, password, remoteEncryptedKey);
|
||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||
} else if (state.decision === "upload") {
|
||||
const remoteObjMeta = await uploadToRemote(
|
||||
s3Client,
|
||||
s3Config,
|
||||
const remoteObjMeta = await client.uploadToRemote(
|
||||
state.key,
|
||||
vault,
|
||||
false,
|
||||
password,
|
||||
remoteEncryptedKey
|
||||
remoteEncryptedKey,
|
||||
foldersCreatedBefore
|
||||
);
|
||||
await upsertSyncMetaMappingDataS3(
|
||||
await upsertSyncMetaMappingData(
|
||||
client.serviceType,
|
||||
db,
|
||||
state.key,
|
||||
state.mtime_local,
|
||||
state.size_local,
|
||||
state.key,
|
||||
remoteObjMeta.LastModified.valueOf(),
|
||||
remoteObjMeta.ContentLength,
|
||||
remoteObjMeta.ETag
|
||||
remoteObjMeta.lastModified,
|
||||
remoteObjMeta.size,
|
||||
remoteObjMeta.etag
|
||||
);
|
||||
} else if (state.decision === "clearhist") {
|
||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||
} else {
|
||||
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;
|
||||
await dispatchOperationToActual(
|
||||
k as string,
|
||||
v as FileOrFolderMixedState,
|
||||
client,
|
||||
db,
|
||||
vault,
|
||||
password,
|
||||
foldersCreatedBefore
|
||||
);
|
||||
// console.log(`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
|
||||
// )
|
||||
// )
|
||||
// );
|
||||
};
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
import { Buffer } from "buffer";
|
||||
import { FileStats, Vault } from "obsidian";
|
||||
import { AuthType, BufferLike, createClient } from "webdav/web";
|
||||
import type { WebDAVClient, ResponseDataDetailed, FileStat } from "webdav/web";
|
||||
export type { WebDAVClient } from "webdav/web";
|
||||
|
||||
import type { RemoteItem } from "./baseTypes";
|
||||
|
||||
import {
|
||||
arrayBufferToBuffer,
|
||||
bufferToArrayBuffer,
|
||||
mkdirpInVault,
|
||||
getPathFolder,
|
||||
} from "./misc";
|
||||
import { decryptArrayBuffer, encryptArrayBuffer } from "./encrypt";
|
||||
|
||||
export type WebdavAuthType = "digest" | "basic";
|
||||
|
||||
export interface WebdavConfig {
|
||||
address: string;
|
||||
username: string;
|
||||
password: string;
|
||||
authType: WebdavAuthType;
|
||||
}
|
||||
|
||||
export const DEFAULT_WEBDAV_CONFIG = {
|
||||
address: "",
|
||||
username: "",
|
||||
password: "",
|
||||
authType: "basic",
|
||||
} as WebdavConfig;
|
||||
|
||||
const getWebdavPath = (fileOrFolderPath: string) => {
|
||||
if (!fileOrFolderPath.startsWith("/")) {
|
||||
return `/${fileOrFolderPath}`;
|
||||
}
|
||||
return fileOrFolderPath;
|
||||
};
|
||||
|
||||
const getNormPath = (fileOrFolderPath: string) => {
|
||||
if (fileOrFolderPath.startsWith("/")) {
|
||||
return fileOrFolderPath.slice(1);
|
||||
}
|
||||
return fileOrFolderPath;
|
||||
};
|
||||
|
||||
const fromWebdavItemToRemoteItem = (x: FileStat) => {
|
||||
let key = getNormPath(x.filename);
|
||||
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 const getWebdavClient = (webdavConfig: WebdavConfig) => {
|
||||
if (webdavConfig.username !== "" && webdavConfig.password !== "") {
|
||||
return createClient(webdavConfig.address, {
|
||||
username: webdavConfig.username,
|
||||
password: webdavConfig.password,
|
||||
authType:
|
||||
webdavConfig.authType === "digest"
|
||||
? AuthType.Digest
|
||||
: AuthType.Password,
|
||||
});
|
||||
} else {
|
||||
console.log("no password");
|
||||
return createClient(webdavConfig.address);
|
||||
}
|
||||
};
|
||||
|
||||
export const getRemoteMeta = async (
|
||||
client: WebDAVClient,
|
||||
fileOrFolderPath: string
|
||||
) => {
|
||||
const res = (await client.stat(getWebdavPath(fileOrFolderPath), {
|
||||
details: false,
|
||||
})) as FileStat;
|
||||
return fromWebdavItemToRemoteItem(res);
|
||||
};
|
||||
|
||||
export const uploadToRemote = async (
|
||||
client: WebDAVClient,
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault,
|
||||
isRecursively: boolean = false,
|
||||
password: string = "",
|
||||
remoteEncryptedKey: string = ""
|
||||
) => {
|
||||
let uploadFile = fileOrFolderPath;
|
||||
if (password !== "") {
|
||||
uploadFile = remoteEncryptedKey;
|
||||
}
|
||||
uploadFile = getWebdavPath(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
|
||||
await 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.putFileContents(uploadFile, "", {
|
||||
overwrite: true,
|
||||
onUploadProgress: (progress) => {
|
||||
console.log(`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.createDirectory(dir, { recursive: true });
|
||||
}
|
||||
await client.putFileContents(uploadFile, remoteContent, {
|
||||
overwrite: true,
|
||||
onUploadProgress: (progress) => {
|
||||
console.log(`Uploaded ${progress.loaded} bytes of ${progress.total}`);
|
||||
},
|
||||
});
|
||||
|
||||
return await getRemoteMeta(client, uploadFile);
|
||||
}
|
||||
};
|
||||
|
||||
export const listFromRemote = async (client: WebDAVClient, prefix?: string) => {
|
||||
if (prefix !== undefined) {
|
||||
throw Error("prefix not supported");
|
||||
}
|
||||
const contents = (await client.getDirectoryContents("/", {
|
||||
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)),
|
||||
};
|
||||
};
|
||||
|
||||
const downloadFromRemoteRaw = async (
|
||||
client: WebDAVClient,
|
||||
fileOrFolderPath: string
|
||||
) => {
|
||||
const buff = (await client.getFileContents(
|
||||
getWebdavPath(fileOrFolderPath)
|
||||
)) 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: WebDAVClient,
|
||||
fileOrFolderPath: string,
|
||||
vault: Vault,
|
||||
mtime: number,
|
||||
password: string = "",
|
||||
remoteEncryptedKey: string = ""
|
||||
) => {
|
||||
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);
|
||||
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: WebDAVClient,
|
||||
fileOrFolderPath: string,
|
||||
password: string = "",
|
||||
remoteEncryptedKey: string = ""
|
||||
) => {
|
||||
if (fileOrFolderPath === "/") {
|
||||
return;
|
||||
}
|
||||
let remoteFileName = fileOrFolderPath;
|
||||
if (password !== "") {
|
||||
remoteFileName = remoteEncryptedKey;
|
||||
}
|
||||
remoteFileName = getWebdavPath(remoteFileName);
|
||||
try {
|
||||
await client.deleteFile(remoteFileName);
|
||||
console.log(`delete ${remoteFileName} succeeded`);
|
||||
} catch (err) {
|
||||
console.error("some error while deleting");
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
export const checkConnectivity = async (client: WebDAVClient) => {
|
||||
try {
|
||||
const results = await getRemoteMeta(client, "/");
|
||||
if (results === undefined) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
+29
@@ -3,3 +3,32 @@
|
||||
.password_second_confirm {
|
||||
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;
|
||||
}
|
||||
|
||||
.webdav-disclaimer {
|
||||
font-weight: bold;
|
||||
}
|
||||
.webdav-hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -60,10 +60,10 @@ describe("Encryption tests", () => {
|
||||
);
|
||||
|
||||
// two command returns same result:
|
||||
// cat ./sometext.txt | openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 10000 -base64 -pass pass:somepassword
|
||||
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 10000 -base64 -pass pass:somepassword -in ./sometext.txt
|
||||
// cat ./sometext.txt | openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -base64 -pass pass:somepassword
|
||||
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -base64 -pass pass:somepassword -in ./sometext.txt
|
||||
const opensslBase64Res =
|
||||
"U2FsdGVkX1+DAvWG+rSR7MSa+yJav1zCE7SSXiBooqwI5Q+LMpIthpk/pXkLj+25";
|
||||
"U2FsdGVkX1+DAvWG+rSR7BPXMnlvSSVGMdjsx7kE1CTH+28P+yAZRdDGgFWMGkMd";
|
||||
// we output base32, so we need some transformation
|
||||
const opensslBase32Res = base64ToBase32(opensslBase64Res);
|
||||
|
||||
@@ -89,6 +89,8 @@ describe("Encryption tests", () => {
|
||||
await fs.readFileSync(path.join(testFolder, testFileName + ".enc"))
|
||||
);
|
||||
|
||||
// openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 20000 -pass pass:somepassword -in mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg -out mona_lisa/1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg.enc
|
||||
|
||||
expect(Buffer.from(enc).equals(Buffer.from(opensslArrBuf))).to.be.true;
|
||||
});
|
||||
|
||||
|
||||
@@ -69,6 +69,28 @@ describe("Misc: get folder levels", () => {
|
||||
const res3 = ["xxx", "xxx/yyy", "xxx/yyy/zzz"];
|
||||
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
|
||||
});
|
||||
|
||||
it("should treat path starting with / correctly", () => {
|
||||
const item = "/xxx/yyy/zzz.md";
|
||||
const res = ["/xxx", "/xxx/yyy"];
|
||||
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
||||
|
||||
const item2 = "/xxx/yyy/zzz";
|
||||
const res2 = ["/xxx", "/xxx/yyy"];
|
||||
expect(misc.getFolderLevels(item2)).to.deep.equal(res2);
|
||||
|
||||
const item3 = "/xxx/yyy/zzz/";
|
||||
const res3 = ["/xxx", "/xxx/yyy", "/xxx/yyy/zzz"];
|
||||
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
|
||||
|
||||
const item4 = "/xxx";
|
||||
const res4 = [] as string[];
|
||||
expect(misc.getFolderLevels(item4)).to.deep.equal(res4);
|
||||
|
||||
const item5 = "/";
|
||||
const res5 = [] as string[];
|
||||
expect(misc.getFolderLevels(item5)).to.deep.equal(res5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Misc: vaild file name tests", () => {
|
||||
@@ -89,3 +111,25 @@ describe("Misc: vaild file name tests", () => {
|
||||
expect(x).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Misc: get dirname", () => {
|
||||
it("should return itself for folder", async () => {
|
||||
const x = misc.getPathFolder("ssss/");
|
||||
// console.log(x)
|
||||
expect(x).to.equal("ssss/");
|
||||
});
|
||||
|
||||
it("should return folder for file", async () => {
|
||||
const x = misc.getPathFolder("sss/yyy");
|
||||
// console.log(x)
|
||||
expect(x).to.equal("sss/");
|
||||
});
|
||||
|
||||
it("should treat / specially", async () => {
|
||||
const x = misc.getPathFolder("/");
|
||||
expect(x).to.equal("/");
|
||||
|
||||
const y = misc.getPathFolder("/abc");
|
||||
expect(y).to.equal("/");
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cf67abd791f1e230bce57dd47150f262424247cbefb8be16efcd57d13955576a
|
||||
oid sha256:7687eaf60ed93f754b107dc86a437aa97fad7bedd40432171b08c22cded7b559
|
||||
size 1141568
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// "allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["dom", "es5", "scripthost", "es2015"]
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"0.0.14": "0.12.15"
|
||||
"0.1.10": "0.12.15"
|
||||
}
|
||||
|
||||
+7
-1
@@ -1,7 +1,10 @@
|
||||
require("dotenv").config();
|
||||
const path = require("path");
|
||||
const webpack = require("webpack");
|
||||
const TerserPlugin = require("terser-webpack-plugin");
|
||||
|
||||
const DEFAULT_DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY || "";
|
||||
|
||||
module.exports = {
|
||||
entry: "./src/main.ts",
|
||||
target: "web",
|
||||
@@ -11,6 +14,9 @@ module.exports = {
|
||||
libraryTarget: "commonjs",
|
||||
},
|
||||
plugins: [
|
||||
new webpack.DefinePlugin({
|
||||
"process.env.DEFAULT_DROPBOX_APP_KEY": `"${DEFAULT_DROPBOX_APP_KEY}"`,
|
||||
}),
|
||||
// Work around for Buffer is undefined:
|
||||
// https://github.com/webpack/changelog-v5/issues/10
|
||||
new webpack.ProvidePlugin({
|
||||
@@ -37,7 +43,7 @@ module.exports = {
|
||||
// buffer: require.resolve("buffer/"),
|
||||
// console: require.resolve("console-browserify"),
|
||||
// constants: require.resolve("constants-browserify"),
|
||||
// crypto: require.resolve("crypto-browserify"),
|
||||
crypto: require.resolve("crypto-browserify"),
|
||||
// domain: require.resolve("domain-browser"),
|
||||
// events: require.resolve("events"),
|
||||
// http: require.resolve("stream-http"),
|
||||
|
||||
Reference in New Issue
Block a user