Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be2b168c6c | ||
|
|
6b296d6110 | ||
|
|
6757348ac9 | ||
|
|
a7e6bed071 | ||
|
|
8b8c5bdb98 | ||
|
|
5b77b23e9f | ||
|
|
05313f9530 | ||
|
|
ce4e55fcc7 | ||
|
|
b26d6a7ffb | ||
|
|
ea12a14d7f | ||
|
|
3609a78465 | ||
|
|
7ec2db4006 | ||
|
|
5352c7b828 | ||
|
|
2a49bb53b2 | ||
|
|
fde4c9a294 | ||
|
|
7fc38a942a | ||
|
|
cc0c4fe224 | ||
|
|
2ab5640c12 | ||
|
|
b7a0376a0f | ||
|
|
d7d1c47e2a | ||
|
|
a65f5eb004 | ||
|
|
2ce34379ad | ||
|
|
40d2ee95b7 | ||
|
|
5632e28331 | ||
|
|
2b53a1bf5e | ||
|
|
ccd9e3116c | ||
|
|
e8d6a50793 | ||
|
|
c43b36e0e7 | ||
|
|
bcd92d135f | ||
|
|
62aea9d330 | ||
|
|
b9c17076b4 | ||
|
|
385290c1cc | ||
|
|
371089e3ee | ||
|
|
254790cd61 | ||
|
|
d3b812c8fa | ||
|
|
3a14f86880 | ||
|
|
887f948dc3 | ||
|
|
035beb7d97 | ||
|
|
f334198241 | ||
|
|
bcca2b4446 | ||
|
|
659deb9830 | ||
|
|
b6df3f7ed5 | ||
|
|
2012758383 | ||
|
|
bea0e694f6 | ||
|
|
8fbbae6c2e | ||
|
|
ec2fec9bf8 | ||
|
|
09d33523b6 | ||
|
|
170cbbfb4f | ||
|
|
9d5fb7e966 | ||
|
|
dc11d21d72 | ||
|
|
774e31ef27 | ||
|
|
7c4a981434 | ||
|
|
358311e8ac | ||
|
|
2551462003 | ||
|
|
60d5b78df0 | ||
|
|
f04017e05b | ||
|
|
cb0098ec85 | ||
|
|
d007d5f230 | ||
|
|
07c6fbe747 | ||
|
|
5e3d413b33 | ||
|
|
bcf8586fbf | ||
|
|
6431182ec3 |
@@ -0,0 +1,5 @@
|
|||||||
|
*.png filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pdf filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.enc filter=lfs diff=lfs merge=lfs -text
|
||||||
@@ -5,9 +5,9 @@ name: BuildCI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master, enc]
|
branches: [master]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master, enc]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -19,12 +19,18 @@ jobs:
|
|||||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- name: Checkout codes
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
- name: Checkout LFS
|
||||||
|
run: git lfs checkout
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
- run: npm install
|
- run: npm install
|
||||||
|
- run: npm test
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v2
|
||||||
with:
|
with:
|
||||||
@@ -32,3 +38,4 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
main.js
|
main.js
|
||||||
manifest.json
|
manifest.json
|
||||||
|
styles.css
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
This is yet another sync plugin for Obsidian.
|
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.
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
**This is NOT the official sync service provided by Obsidian.**
|
**This is NOT the official sync service provided by Obsidian.**
|
||||||
@@ -14,5 +22,5 @@ As of October 2021, the plugin is under development. **DO NOT USE IT for any ser
|
|||||||
|
|
||||||
Remote service in support list:
|
Remote service in support list:
|
||||||
|
|
||||||
- [ ] AWS S3
|
- [x] AWS S3
|
||||||
- [ ] webdav
|
- [ ] webdav
|
||||||
|
|||||||
+13
-13
@@ -14,16 +14,16 @@ Assuming all sources are reliable.
|
|||||||
|
|
||||||
We list all combinations mutually exclusive and collectively exhaustive.
|
We list all combinations mutually exclusive and collectively exhaustive.
|
||||||
|
|
||||||
| ID | Remote Files | Local files | Local delete rename history | Extra | Decision |
|
| ID | Remote Files | Local files | Local delete rename history | Extra | Decision |
|
||||||
| --- | ------------ | ----------- | --------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
| --- | ------------ | ----------- | --------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
| 1 | exist | exist | ignore | mtime_remote > mtime_local | download remote file, create local folder if not exists, clear local history if exists |
|
| 1 | exist | exist | ignore | mtime_remote > mtime_local | download remote file, create local folder if not exists, clear local history if exists |
|
||||||
| 2 | exist | exist | ignore | mtime_remote < mtime_local | upload local file, create remote folder if not exists, clear local history if exists |
|
| 2 | exist | exist | ignore | mtime_remote < mtime_local | upload local file, create remote folder if not exists, clear local history if exists |
|
||||||
| 3 | exist | exist | ignore | mtime_remote === mtime_local && size_remote === size_local | clear local history if exists (the file was synced and no changes after last sync) |
|
| 3 | exist | exist | ignore | mtime_remote === mtime_local && password === "" && size_remote === size_local | clear local history if exists (the file was synced and no changes after last sync) |
|
||||||
| 4 | exist | exist | ignore | mtime_remote === mtime_local && size_remote !== size_local | upload local file, clear local history if exists (we always prefer local to remote) |
|
| 4 | exist | exist | ignore | mtime_remote === mtime_local && password === "" && size_remote !== size_local | upload local file, clear local history if exists (we always prefer local to remote) |
|
||||||
| 5 | exist | exist | ignore | If local is a folder. mtime_local === undefined | clear local history if exists. TODO: what if a folder and a previous file share the same name? |
|
| 5 | exist | exist | ignore | mtime_remote === mtime_local && password !== "" | clear local history if exists (in encryption mode, file sizes are unequal. we can only rely on mtime(s)) |
|
||||||
| 6 | exist | not exist | exist | mtime_remote >= delete_time_local | download remote file, create folder if not exists |
|
| 6 | exist | exist | ignore | If local is a folder. mtime_local === undefined | clear local history if exists. TODO: what if a folder and a previous file share the same name? |
|
||||||
| 7 | exist | not exist | exist | mtime_remote < delete_time_local | delete remote file, clear local history |
|
| 7 | exist | not exist | exist | mtime_remote >= delete_time_local | download remote file, create folder if not exists |
|
||||||
| 8 | exist | not exist | not exist | | download remote file, create folder if not exists |
|
| 8 | exist | not exist | exist | mtime_remote < delete_time_local | delete remote file, clear local history |
|
||||||
| 9 | not exist | exist | ignore | If local is a single file. | upload local file, create remote folder if not exists, clear local history if exists |
|
| 9 | exist | not exist | not exist | | download remote file, create folder if not exists |
|
||||||
| 10 | not exist | exist | ignore | If local is a folder. | upload local files recursively, create remote folder if not exists, clear local history if exists |
|
| 10 | not exist | exist | ignore | local may be folder or file | upload local files recursively, create remote folder if not exists, clear local history if exists |
|
||||||
| 11 | not exist | not exist | ignore | | clear local history if exists |
|
| 11 | not exist | not exist | ignore | | clear local history if exists |
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "obsdian-save-remote",
|
"id": "obsidian-save-remote",
|
||||||
"name": "Save remote",
|
"name": "Save remote",
|
||||||
"version": "0.0.4",
|
"version": "0.0.14",
|
||||||
"minAppVersion": "0.12.15",
|
"minAppVersion": "0.12.15",
|
||||||
"description": "This is yet another plugin allowing users to sync notes between local device and the cloud.",
|
"description": "This is yet another plugin allowing users to sync notes between local device and the cloud.",
|
||||||
"author": "fyears",
|
"author": "fyears",
|
||||||
|
|||||||
+16
-23
@@ -1,22 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "obsidian-save-remote",
|
"name": "obsidian-save-remote",
|
||||||
"version": "0.0.4",
|
"version": "0.0.14",
|
||||||
"description": "This is yet another sync plugin for Obsidian app.",
|
"description": "This is yet another sync plugin for Obsidian app.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "webpack --mode development --watch",
|
"dev": "webpack --mode development --watch",
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"format": "npx prettier --write .",
|
"format": "npx prettier --write .",
|
||||||
"clean": "npx rimraf main.js"
|
"clean": "npx rimraf main.js",
|
||||||
|
"test": "cross-env TS_NODE_COMPILER_OPTIONS={\\\"module\\\":\\\"commonjs\\\"} mocha -r ts-node/register 'tests/**/*.ts'"
|
||||||
},
|
},
|
||||||
"source": "main.ts",
|
"source": "main.ts",
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/chai": "^4.2.22",
|
||||||
|
"@types/chai-as-promised": "^7.1.4",
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
|
"@types/mocha": "^9.0.0",
|
||||||
"@types/node": "^14.14.37",
|
"@types/node": "^14.14.37",
|
||||||
|
"chai": "^4.3.4",
|
||||||
|
"chai-as-promised": "^7.1.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"mocha": "^9.1.3",
|
||||||
"prettier": "^2.4.1",
|
"prettier": "^2.4.1",
|
||||||
"terser-webpack-plugin": "^5.2.4",
|
"terser-webpack-plugin": "^5.2.4",
|
||||||
"ts-loader": "^9.2.6",
|
"ts-loader": "^9.2.6",
|
||||||
|
"ts-node": "^10.4.0",
|
||||||
"tslib": "^2.2.0",
|
"tslib": "^2.2.0",
|
||||||
"typescript": "^4.4.4",
|
"typescript": "^4.4.4",
|
||||||
"webdav-server": "^2.6.2",
|
"webdav-server": "^2.6.2",
|
||||||
@@ -25,39 +35,22 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.37.0",
|
"@aws-sdk/client-s3": "^3.37.0",
|
||||||
|
"@aws-sdk/lib-storage": "^3.40.1",
|
||||||
"@aws-sdk/signature-v4-crt": "^3.37.0",
|
"@aws-sdk/signature-v4-crt": "^3.37.0",
|
||||||
"@types/mime-types": "^2.1.1",
|
|
||||||
"acorn": "^8.5.0",
|
"acorn": "^8.5.0",
|
||||||
"assert": "^2.0.0",
|
|
||||||
"aws-crt": "^1.10.1",
|
"aws-crt": "^1.10.1",
|
||||||
"browserify-zlib": "^0.2.0",
|
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"codemirror": "^5.63.1",
|
"codemirror": "^5.63.1",
|
||||||
"console-browserify": "^1.2.0",
|
|
||||||
"constants-browserify": "^1.0.0",
|
|
||||||
"crypto-browserify": "^3.12.0",
|
|
||||||
"domain-browser": "^4.22.0",
|
|
||||||
"events": "^3.3.0",
|
|
||||||
"hi-base32": "^0.5.1",
|
|
||||||
"https-browserify": "^1.0.0",
|
|
||||||
"lovefield-ts": "^0.7.0",
|
"lovefield-ts": "^0.7.0",
|
||||||
"mime-types": "^2.1.33",
|
"mime-types": "^2.1.33",
|
||||||
"obsidian": "^0.12.0",
|
"obsidian": "^0.12.0",
|
||||||
"os-browserify": "^0.3.0",
|
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"punycode": "^2.1.1",
|
"rfc4648": "^1.5.0",
|
||||||
"querystring-es3": "^0.2.1",
|
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"stream-http": "^3.2.0",
|
|
||||||
"string_decoder": "^1.3.0",
|
|
||||||
"timers-browserify": "^2.0.12",
|
|
||||||
"tty-browserify": "0.0.1",
|
|
||||||
"url": "^0.11.0",
|
|
||||||
"util": "^0.12.4",
|
|
||||||
"vm-browserify": "^1.1.2",
|
|
||||||
"webdav": "^4.7.0",
|
"webdav": "^4.7.0",
|
||||||
"webdav-fs": "^4.0.0"
|
"webdav-fs": "^4.0.0",
|
||||||
|
"xregexp": "^5.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 { mkdirpInVault } from "./misc";
|
||||||
|
|
||||||
|
const DEFAULT_DEBUG_FOLDER = "_debug_save_remote/";
|
||||||
|
const DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX = "sync_plans_hist_exported_on_";
|
||||||
|
|
||||||
|
export const exportSyncPlansToFiles = async (
|
||||||
|
db: lf.DatabaseConnection,
|
||||||
|
vault: Vault
|
||||||
|
) => {
|
||||||
|
console.log("exporting");
|
||||||
|
await mkdirpInVault(DEFAULT_DEBUG_FOLDER, vault);
|
||||||
|
const records = await readAllSyncPlanRecordTexts(db);
|
||||||
|
let md = "";
|
||||||
|
if (records.length === 0) {
|
||||||
|
md = "No sync plans history found";
|
||||||
|
} else {
|
||||||
|
md =
|
||||||
|
"Sync plans found:\n\n" +
|
||||||
|
records.map((x) => "```json\n" + x + "\n```\n").join("\n");
|
||||||
|
}
|
||||||
|
const ts = Date.now();
|
||||||
|
const filePath = `${DEFAULT_DEBUG_FOLDER}${DEFAULT_SYNC_PLANS_HISTORY_FILE_PREFIX}${ts}.md`;
|
||||||
|
await vault.create(filePath, md, {
|
||||||
|
mtime: ts,
|
||||||
|
});
|
||||||
|
console.log("finish exporting");
|
||||||
|
};
|
||||||
+116
-69
@@ -1,91 +1,138 @@
|
|||||||
import * as crypto from "crypto";
|
import { base32, base64 } from "rfc4648";
|
||||||
import * as base32 from "hi-base32";
|
import {
|
||||||
import { bufferToArrayBuffer, arrayBufferToBuffer } from "./misc";
|
bufferToArrayBuffer,
|
||||||
|
arrayBufferToBuffer,
|
||||||
|
hexStringToTypedArray,
|
||||||
|
arrayBufferToHex,
|
||||||
|
} from "./misc";
|
||||||
|
|
||||||
const DEFAULT_ITER = 10000;
|
const DEFAULT_ITER = 10000;
|
||||||
|
|
||||||
export const encryptBuffer = (
|
// base32.stringify(Buffer.from('Salted__'))
|
||||||
buf: Buffer,
|
export const MAGIC_ENCRYPTED_PREFIX_BASE32 = "KNQWY5DFMRPV";
|
||||||
|
|
||||||
|
const getKeyIVFromPassword = async (
|
||||||
|
salt: Uint8Array,
|
||||||
password: string,
|
password: string,
|
||||||
rounds: number = DEFAULT_ITER
|
rounds: number = DEFAULT_ITER
|
||||||
) => {
|
) => {
|
||||||
const salt = crypto.randomBytes(8);
|
const k1 = await window.crypto.subtle.importKey(
|
||||||
const derivedKey = crypto.pbkdf2Sync(
|
"raw",
|
||||||
password,
|
new TextEncoder().encode(password),
|
||||||
salt,
|
{ name: "PBKDF2" },
|
||||||
rounds,
|
false,
|
||||||
32 + 16,
|
["deriveKey", "deriveBits"]
|
||||||
"sha256"
|
|
||||||
);
|
);
|
||||||
const key = derivedKey.slice(0, 32);
|
|
||||||
const iv = derivedKey.slice(32, 32 + 16);
|
const k2 = await window.crypto.subtle.deriveBits(
|
||||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
{
|
||||||
cipher.write(buf);
|
name: "PBKDF2",
|
||||||
cipher.end();
|
salt: salt,
|
||||||
const encrypted = cipher.read();
|
iterations: rounds,
|
||||||
const res = Buffer.concat([Buffer.from("Salted__"), salt, encrypted]);
|
hash: "SHA-256",
|
||||||
return res;
|
},
|
||||||
|
k1,
|
||||||
|
256 + 128
|
||||||
|
);
|
||||||
|
|
||||||
|
return k2;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const decryptBuffer = (
|
export const encryptArrayBuffer = async (
|
||||||
buf: Buffer,
|
arrBuf: ArrayBuffer,
|
||||||
password: string,
|
password: string,
|
||||||
rounds: number = DEFAULT_ITER
|
rounds: number = DEFAULT_ITER,
|
||||||
|
saltHex: string = ""
|
||||||
) => {
|
) => {
|
||||||
const prefix = buf.slice(0, 8);
|
let salt: Uint8Array;
|
||||||
const salt = buf.slice(8, 16);
|
if (saltHex !== "") {
|
||||||
const derivedKey = crypto.pbkdf2Sync(
|
salt = hexStringToTypedArray(saltHex);
|
||||||
password,
|
} else {
|
||||||
salt,
|
salt = window.crypto.getRandomValues(new Uint8Array(8));
|
||||||
rounds,
|
}
|
||||||
32 + 16,
|
|
||||||
"sha256"
|
const derivedKey = await getKeyIVFromPassword(salt, password, rounds);
|
||||||
);
|
|
||||||
const key = derivedKey.slice(0, 32);
|
const key = derivedKey.slice(0, 32);
|
||||||
const iv = derivedKey.slice(32, 32 + 16);
|
const iv = derivedKey.slice(32, 32 + 16);
|
||||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
||||||
decipher.write(buf.slice(16));
|
const keyCrypt = await window.crypto.subtle.importKey(
|
||||||
decipher.end();
|
"raw",
|
||||||
const decrypted = decipher.read();
|
key,
|
||||||
return decrypted as Buffer;
|
{ name: "AES-CBC" },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const enc = (await window.crypto.subtle.encrypt(
|
||||||
|
{ name: "AES-CBC", iv },
|
||||||
|
keyCrypt,
|
||||||
|
arrBuf
|
||||||
|
)) as ArrayBuffer;
|
||||||
|
|
||||||
|
const prefix = new TextEncoder().encode("Salted__");
|
||||||
|
|
||||||
|
const res = new Uint8Array([...prefix, ...salt, ...new Uint8Array(enc)]);
|
||||||
|
|
||||||
|
return bufferToArrayBuffer(res);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const encryptArrayBuffer = (
|
export const decryptArrayBuffer = async (
|
||||||
arrBuf: ArrayBuffer,
|
arrBuf: ArrayBuffer,
|
||||||
password: string,
|
password: string,
|
||||||
rounds: number = DEFAULT_ITER
|
rounds: number = DEFAULT_ITER
|
||||||
) => {
|
) => {
|
||||||
return bufferToArrayBuffer(
|
const prefix = arrBuf.slice(0, 8);
|
||||||
encryptBuffer(arrayBufferToBuffer(arrBuf), password, rounds)
|
const salt = arrBuf.slice(8, 16);
|
||||||
);
|
const derivedKey = await getKeyIVFromPassword(
|
||||||
};
|
new Uint8Array(salt),
|
||||||
|
|
||||||
export const decryptArrayBuffer = (
|
|
||||||
arrBuf: ArrayBuffer,
|
|
||||||
password: string,
|
|
||||||
rounds: number = DEFAULT_ITER
|
|
||||||
) => {
|
|
||||||
return bufferToArrayBuffer(
|
|
||||||
decryptBuffer(arrayBufferToBuffer(arrBuf), password, rounds)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const encryptStringToBase32 = (
|
|
||||||
text: string,
|
|
||||||
password: string,
|
|
||||||
rounds: number = DEFAULT_ITER
|
|
||||||
) => {
|
|
||||||
return base32.encode(encryptBuffer(Buffer.from(text), password, rounds));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const decryptBase32ToString = (
|
|
||||||
text: string,
|
|
||||||
password: string,
|
|
||||||
rounds: number = DEFAULT_ITER
|
|
||||||
) => {
|
|
||||||
return decryptBuffer(
|
|
||||||
Buffer.from(base32.decode.asBytes(text)),
|
|
||||||
password,
|
password,
|
||||||
rounds
|
rounds
|
||||||
).toString();
|
);
|
||||||
|
const key = derivedKey.slice(0, 32);
|
||||||
|
const iv = derivedKey.slice(32, 32 + 16);
|
||||||
|
|
||||||
|
const keyCrypt = await window.crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
key,
|
||||||
|
{ name: "AES-CBC" },
|
||||||
|
false,
|
||||||
|
["encrypt", "decrypt"]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dec = (await window.crypto.subtle.decrypt(
|
||||||
|
{ name: "AES-CBC", iv },
|
||||||
|
keyCrypt,
|
||||||
|
arrBuf.slice(16)
|
||||||
|
)) as ArrayBuffer;
|
||||||
|
|
||||||
|
return dec;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encryptStringToBase32 = async (
|
||||||
|
text: string,
|
||||||
|
password: string,
|
||||||
|
rounds: number = DEFAULT_ITER,
|
||||||
|
saltHex: string = ""
|
||||||
|
) => {
|
||||||
|
const enc = await encryptArrayBuffer(
|
||||||
|
bufferToArrayBuffer(new TextEncoder().encode(text)),
|
||||||
|
password,
|
||||||
|
rounds,
|
||||||
|
saltHex
|
||||||
|
);
|
||||||
|
return base32.stringify(new Uint8Array(enc));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const decryptBase32ToString = async (
|
||||||
|
text: string,
|
||||||
|
password: string,
|
||||||
|
rounds: number = DEFAULT_ITER
|
||||||
|
) => {
|
||||||
|
return new TextDecoder().decode(
|
||||||
|
await decryptArrayBuffer(
|
||||||
|
bufferToArrayBuffer(base32.parse(text)),
|
||||||
|
password,
|
||||||
|
rounds
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import * as lf from "lovefield-ts/dist/es6/lf.js";
|
|||||||
import { TAbstractFile, TFile, TFolder } from "obsidian";
|
import { TAbstractFile, TFile, TFolder } from "obsidian";
|
||||||
|
|
||||||
import type { SUPPORTED_SERVICES_TYPE } from "./misc";
|
import type { SUPPORTED_SERVICES_TYPE } from "./misc";
|
||||||
|
import type { SyncPlanType } from "./sync";
|
||||||
|
|
||||||
export type DatabaseConnection = lf.DatabaseConnection;
|
export type DatabaseConnection = lf.DatabaseConnection;
|
||||||
|
|
||||||
export const DEFAULT_DB_NAME = "saveremotedb";
|
export const DEFAULT_DB_NAME = "saveremotedb";
|
||||||
export const DEFAULT_TBL_DELETE_HISTORY = "filefolderoperationhistory";
|
export const DEFAULT_TBL_DELETE_HISTORY = "filefolderoperationhistory";
|
||||||
export const DEFAULT_TBL_SYNC_MAPPING = "syncmetadatahistory";
|
export const DEFAULT_TBL_SYNC_MAPPING = "syncmetadatahistory";
|
||||||
|
export const DEFAULT_SYNC_PLANS_HISTORY = "syncplanshistory";
|
||||||
|
|
||||||
export interface FileFolderHistoryRecord {
|
export interface FileFolderHistoryRecord {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -32,6 +34,12 @@ export interface SyncMetaMappingRecord {
|
|||||||
key_type: "folder" | "file";
|
key_type: "folder" | "file";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SyncPlanRecord {
|
||||||
|
ts: number;
|
||||||
|
remote_type: string;
|
||||||
|
sync_plan: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const prepareDBs = async () => {
|
export const prepareDBs = async () => {
|
||||||
const schemaBuilder = lf.schema.create(DEFAULT_DB_NAME, 1);
|
const schemaBuilder = lf.schema.create(DEFAULT_DB_NAME, 1);
|
||||||
schemaBuilder
|
schemaBuilder
|
||||||
@@ -68,6 +76,15 @@ export const prepareDBs = async () => {
|
|||||||
.addPrimaryKey(["id"], true)
|
.addPrimaryKey(["id"], true)
|
||||||
.addIndex("idxkey", ["local_key", "remote_key"]);
|
.addIndex("idxkey", ["local_key", "remote_key"]);
|
||||||
|
|
||||||
|
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({
|
const db = await schemaBuilder.connect({
|
||||||
storeType: lf.DataStoreType.INDEXED_DB,
|
storeType: lf.DataStoreType.INDEXED_DB,
|
||||||
});
|
});
|
||||||
@@ -258,3 +275,42 @@ export const getSyncMetaMappingByRemoteKeyS3 = async (
|
|||||||
|
|
||||||
throw Error("something bad in sync meta mapping!");
|
throw Error("something bad in sync meta mapping!");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const clearAllSyncMetaMapping = async (db: lf.DatabaseConnection) => {
|
||||||
|
const tbl = db.getSchema().table(DEFAULT_TBL_SYNC_MAPPING);
|
||||||
|
await db.delete().from(tbl).exec();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertSyncPlanRecord = async (
|
||||||
|
db: lf.DatabaseConnection,
|
||||||
|
syncPlan: SyncPlanType
|
||||||
|
) => {
|
||||||
|
const schema = db.getSchema().table(DEFAULT_SYNC_PLANS_HISTORY);
|
||||||
|
const row = schema.createRow({
|
||||||
|
ts: syncPlan.ts,
|
||||||
|
remote_type: syncPlan.remoteType,
|
||||||
|
sync_plan: JSON.stringify(syncPlan, null, 2),
|
||||||
|
} as SyncPlanRecord);
|
||||||
|
await db.insertOrReplace().into(schema).values([row]).exec();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllSyncPlanRecords = async (db: lf.DatabaseConnection) => {
|
||||||
|
const tbl = db.getSchema().table(DEFAULT_SYNC_PLANS_HISTORY);
|
||||||
|
await db.delete().from(tbl).exec();
|
||||||
|
};
|
||||||
|
|
||||||
|
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[];
|
||||||
|
|
||||||
|
if (records === undefined) {
|
||||||
|
return [] as string[];
|
||||||
|
} else {
|
||||||
|
return records.map((x) => x.sync_plan);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
+241
-68
@@ -11,7 +11,11 @@ import {
|
|||||||
TFolder,
|
TFolder,
|
||||||
} from "obsidian";
|
} from "obsidian";
|
||||||
import * as CodeMirror from "codemirror";
|
import * as CodeMirror from "codemirror";
|
||||||
import type { DatabaseConnection } from "./localdb";
|
import {
|
||||||
|
clearAllSyncPlanRecords,
|
||||||
|
clearAllSyncMetaMapping,
|
||||||
|
DatabaseConnection,
|
||||||
|
} from "./localdb";
|
||||||
import {
|
import {
|
||||||
prepareDBs,
|
prepareDBs,
|
||||||
destroyDBs,
|
destroyDBs,
|
||||||
@@ -19,11 +23,19 @@ import {
|
|||||||
insertDeleteRecord,
|
insertDeleteRecord,
|
||||||
insertRenameRecord,
|
insertRenameRecord,
|
||||||
getAllDeleteRenameRecords,
|
getAllDeleteRenameRecords,
|
||||||
|
insertSyncPlanRecord,
|
||||||
} from "./localdb";
|
} from "./localdb";
|
||||||
|
|
||||||
import type { SyncStatusType } from "./sync";
|
import type { SyncStatusType, PasswordCheckType } from "./sync";
|
||||||
import { ensembleMixedStates, getOperation, doActualSync } from "./sync";
|
import { isPasswordOk, getSyncPlan, doActualSync } from "./sync";
|
||||||
import { DEFAULT_S3_CONFIG, getS3Client, listFromRemote, S3Config } from "./s3";
|
import {
|
||||||
|
DEFAULT_S3_CONFIG,
|
||||||
|
getS3Client,
|
||||||
|
listFromRemote,
|
||||||
|
S3Config,
|
||||||
|
checkS3Connectivity,
|
||||||
|
} from "./s3";
|
||||||
|
import { exportSyncPlansToFiles } from "./debugMode";
|
||||||
|
|
||||||
interface SaveRemotePluginSettings {
|
interface SaveRemotePluginSettings {
|
||||||
s3?: S3Config;
|
s3?: S3Config;
|
||||||
@@ -62,59 +74,79 @@ export default class SaveRemotePlugin extends Plugin {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// this.addRibbonIcon("dice", "Misc", async () => {
|
|
||||||
// const a = this.app.vault.getAllLoadedFiles();
|
|
||||||
// console.log(a);
|
|
||||||
// const h = await getAllRecords(this.db);
|
|
||||||
// console.log(h);
|
|
||||||
// });
|
|
||||||
|
|
||||||
this.addRibbonIcon("switch", "Save Remote", async () => {
|
this.addRibbonIcon("switch", "Save Remote", async () => {
|
||||||
if (this.syncStatus !== "idle") {
|
if (this.syncStatus !== "idle") {
|
||||||
new Notice("Save Remote already running!");
|
new Notice(`Save Remote already running in stage ${this.syncStatus}!`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
new Notice("Save Remote Sync Preparing");
|
try {
|
||||||
this.syncStatus = "preparing";
|
//console.log(`huh ${this.settings.password}`)
|
||||||
const s3Client = getS3Client(this.settings.s3);
|
new Notice("1/6 Save Remote Sync Preparing");
|
||||||
const remoteRsp = await listFromRemote(s3Client, this.settings.s3);
|
this.syncStatus = "preparing";
|
||||||
const local = this.app.vault.getAllLoadedFiles();
|
|
||||||
const localHistory = await loadDeleteRenameHistoryTable(this.db);
|
|
||||||
// console.log(remoteRsp);
|
|
||||||
// console.log(local);
|
|
||||||
// console.log(localHistory);
|
|
||||||
|
|
||||||
const mixedStates = await ensembleMixedStates(
|
new Notice("2/6 Starting to fetch remote meta data.");
|
||||||
remoteRsp.Contents,
|
this.syncStatus = "getting_remote_meta";
|
||||||
local,
|
const s3Client = getS3Client(this.settings.s3);
|
||||||
localHistory,
|
const remoteRsp = await listFromRemote(s3Client, this.settings.s3);
|
||||||
this.db,
|
|
||||||
this.settings.password
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const [key, val] of Object.entries(mixedStates)) {
|
new Notice("3/6 Starting to fetch local meta data.");
|
||||||
getOperation(val, true);
|
this.syncStatus = "getting_local_meta";
|
||||||
|
const local = this.app.vault.getAllLoadedFiles();
|
||||||
|
const localHistory = await loadDeleteRenameHistoryTable(this.db);
|
||||||
|
// console.log(remoteRsp);
|
||||||
|
// console.log(local);
|
||||||
|
// console.log(localHistory);
|
||||||
|
|
||||||
|
new Notice("4/7 Checking password correct or not.");
|
||||||
|
this.syncStatus = "checking_password";
|
||||||
|
const passwordCheckResult = await isPasswordOk(
|
||||||
|
remoteRsp.Contents,
|
||||||
|
this.settings.password
|
||||||
|
);
|
||||||
|
if (!passwordCheckResult.ok) {
|
||||||
|
new Notice("something goes wrong while checking password");
|
||||||
|
throw Error(passwordCheckResult.reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
new Notice("5/7 Starting to generate sync plan.");
|
||||||
|
this.syncStatus = "generating_plan";
|
||||||
|
const syncPlan = await getSyncPlan(
|
||||||
|
remoteRsp.Contents,
|
||||||
|
local,
|
||||||
|
localHistory,
|
||||||
|
this.db,
|
||||||
|
this.settings.password
|
||||||
|
);
|
||||||
|
console.log(syncPlan.mixedStates); // for debugging
|
||||||
|
await insertSyncPlanRecord(this.db, syncPlan);
|
||||||
|
|
||||||
|
// The operations above are read only and kind of safe.
|
||||||
|
// The operations below begins to write or delete (!!!) something.
|
||||||
|
|
||||||
|
new Notice("6/7 Save Remote Sync data exchanging!");
|
||||||
|
|
||||||
|
this.syncStatus = "syncing";
|
||||||
|
await doActualSync(
|
||||||
|
s3Client,
|
||||||
|
this.settings.s3,
|
||||||
|
this.db,
|
||||||
|
this.app.vault,
|
||||||
|
syncPlan,
|
||||||
|
this.settings.password
|
||||||
|
);
|
||||||
|
|
||||||
|
new Notice("7/7 Save Remote finish!");
|
||||||
|
this.syncStatus = "finish";
|
||||||
|
this.syncStatus = "idle";
|
||||||
|
} catch (error) {
|
||||||
|
const msg = `Save Remote error while ${this.syncStatus}`;
|
||||||
|
console.log(msg);
|
||||||
|
console.log(error);
|
||||||
|
new Notice(msg);
|
||||||
|
new Notice(error.message);
|
||||||
|
this.syncStatus = "idle";
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(mixedStates);
|
|
||||||
|
|
||||||
// The operations above are read only and kind of safe.
|
|
||||||
// The operations below begins to write or delete (!!!) something.
|
|
||||||
|
|
||||||
new Notice("Save Remote Sync data exchanging!");
|
|
||||||
|
|
||||||
await doActualSync(
|
|
||||||
s3Client,
|
|
||||||
this.settings.s3,
|
|
||||||
this.db,
|
|
||||||
this.app.vault,
|
|
||||||
mixedStates,
|
|
||||||
this.settings.password
|
|
||||||
);
|
|
||||||
|
|
||||||
new Notice("Save Remote finish!");
|
|
||||||
this.syncStatus = "idle";
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addSettingTab(new SaveRemoteSettingTab(this.app, this));
|
this.addSettingTab(new SaveRemoteSettingTab(this.app, this));
|
||||||
@@ -151,7 +183,63 @@ export default class SaveRemotePlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroyDBs() {
|
destroyDBs() {
|
||||||
destroyDBs(this.db);
|
/* destroyDBs(this.db); */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PasswordModal extends Modal {
|
||||||
|
plugin: SaveRemotePlugin;
|
||||||
|
newPassword: string;
|
||||||
|
constructor(app: App, plugin: SaveRemotePlugin, newPassword: string) {
|
||||||
|
super(app);
|
||||||
|
this.plugin = plugin;
|
||||||
|
this.newPassword = newPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpen() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
// contentEl.setText("Add Or change password.");
|
||||||
|
contentEl.createEl("h2", { text: "Hold on and PLEASE READ ON..." });
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "This password allows you encrypt your files before sending to remote services.",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", { text: "Empty means no password." });
|
||||||
|
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Attention 1/4: The password setting itself is stored in PLAIN TEXT LOCALLY (because the plugin needs to use the password to encrypt the files) (and the password would not be sent to remote by this plugin).",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Attention 2/4: The file contents are encrypted using openssl format. BUT, some metadata such as file sizes and directory structures are not encrypted or can be easily guessed.",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Attention 3/4: If you change the password. You should make sure the remote service (s3/webdav/...) IS EMPTY, or REMOTE FILES WERE ENCRYPTED BY THAT NEW PASSWORD. OTHERWISE SOMETHING BAD WOULD HAPPEN!",
|
||||||
|
});
|
||||||
|
contentEl.createEl("p", {
|
||||||
|
text: "Attention 4/4: The longer the password, the better.",
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(contentEl)
|
||||||
|
.addButton((button) => {
|
||||||
|
button.setButtonText("The Second Confirm to change password.");
|
||||||
|
button.onClick(async () => {
|
||||||
|
this.plugin.settings.password = this.newPassword;
|
||||||
|
await this.plugin.saveSettings();
|
||||||
|
new Notice("New password saved!");
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
button.setClass("password_second_confirm");
|
||||||
|
})
|
||||||
|
.addButton((button) => {
|
||||||
|
button.setButtonText("Cancel (password not changed.)");
|
||||||
|
button.onClick(() => {
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose() {
|
||||||
|
let { contentEl } = this;
|
||||||
|
contentEl.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +256,9 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
|||||||
|
|
||||||
containerEl.empty();
|
containerEl.empty();
|
||||||
|
|
||||||
containerEl.createEl("h2", { text: "Settings for Save Remote" });
|
containerEl.createEl("h1", { text: "Save Remote" });
|
||||||
|
|
||||||
|
containerEl.createEl("h2", { text: "S3" });
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("s3Endpoint")
|
.setName("s3Endpoint")
|
||||||
@@ -178,7 +268,7 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
|||||||
.setPlaceholder("")
|
.setPlaceholder("")
|
||||||
.setValue(this.plugin.settings.s3.s3Endpoint)
|
.setValue(this.plugin.settings.s3.s3Endpoint)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.s3.s3Endpoint = value;
|
this.plugin.settings.s3.s3Endpoint = value.trim();
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -191,7 +281,7 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
|||||||
.setPlaceholder("")
|
.setPlaceholder("")
|
||||||
.setValue(`${this.plugin.settings.s3.s3Region}`)
|
.setValue(`${this.plugin.settings.s3.s3Region}`)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.s3.s3Region = value;
|
this.plugin.settings.s3.s3Region = value.trim();
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -204,7 +294,7 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
|||||||
.setPlaceholder("")
|
.setPlaceholder("")
|
||||||
.setValue(`${this.plugin.settings.s3.s3AccessKeyID}`)
|
.setValue(`${this.plugin.settings.s3.s3AccessKeyID}`)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.s3.s3AccessKeyID = value;
|
this.plugin.settings.s3.s3AccessKeyID = value.trim();
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -217,19 +307,7 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
|||||||
.setPlaceholder("")
|
.setPlaceholder("")
|
||||||
.setValue(`${this.plugin.settings.s3.s3SecretAccessKey}`)
|
.setValue(`${this.plugin.settings.s3.s3SecretAccessKey}`)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.s3.s3SecretAccessKey = value;
|
this.plugin.settings.s3.s3SecretAccessKey = value.trim();
|
||||||
await this.plugin.saveSettings();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName("password")
|
|
||||||
.setDesc("password")
|
|
||||||
.addText((text) =>
|
|
||||||
text
|
|
||||||
.setPlaceholder("")
|
|
||||||
.setValue(`${this.plugin.settings.password}`)
|
|
||||||
.onChange(async (value) => {
|
|
||||||
this.plugin.settings.password = value;
|
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -242,9 +320,104 @@ class SaveRemoteSettingTab extends PluginSettingTab {
|
|||||||
.setPlaceholder("")
|
.setPlaceholder("")
|
||||||
.setValue(`${this.plugin.settings.s3.s3BucketName}`)
|
.setValue(`${this.plugin.settings.s3.s3BucketName}`)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.s3.s3BucketName = value;
|
this.plugin.settings.s3.s3BucketName = value.trim();
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("check connectivity")
|
||||||
|
.setDesc("check connectivity")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Check");
|
||||||
|
button.onClick(async () => {
|
||||||
|
new Notice("Checking...");
|
||||||
|
const s3Client = getS3Client(this.plugin.settings.s3);
|
||||||
|
const res = await checkS3Connectivity(
|
||||||
|
s3Client,
|
||||||
|
this.plugin.settings.s3
|
||||||
|
);
|
||||||
|
if (res) {
|
||||||
|
new Notice("Great! The bucket can be accessed.");
|
||||||
|
} else {
|
||||||
|
new Notice("The S3 bucket cannot be reached.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
containerEl.createEl("h2", { text: "General" });
|
||||||
|
|
||||||
|
let newPassword = `${this.plugin.settings.password}`;
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("encryption password")
|
||||||
|
.setDesc(
|
||||||
|
'Password for E2E encryption. Empty for no password. You need to click "Confirm".'
|
||||||
|
)
|
||||||
|
.addText((text) =>
|
||||||
|
text
|
||||||
|
.setPlaceholder("")
|
||||||
|
.setValue(`${this.plugin.settings.password}`)
|
||||||
|
.onChange(async (value) => {
|
||||||
|
newPassword = value.trim();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Confirm");
|
||||||
|
button.onClick(async () => {
|
||||||
|
new PasswordModal(this.app, this.plugin, newPassword).open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
containerEl.createEl("h2", { text: "Debug" });
|
||||||
|
|
||||||
|
const syncPlanDiv = containerEl.createEl("div");
|
||||||
|
syncPlanDiv.createEl("p", {
|
||||||
|
text: "Sync plans are created every time after you trigger sync and before the actual sync.",
|
||||||
|
});
|
||||||
|
syncPlanDiv.createEl("p", {
|
||||||
|
text: "They are useful to know what would actually happen in those sync.",
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("export sync plans")
|
||||||
|
.setDesc("export sync plans")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Export");
|
||||||
|
button.onClick(async () => {
|
||||||
|
await exportSyncPlansToFiles(this.plugin.db, this.app.vault);
|
||||||
|
new Notice("sync plans history exported");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("delete sync plans history in db")
|
||||||
|
.setDesc("delete sync plans history in db")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Delete History");
|
||||||
|
button.onClick(async () => {
|
||||||
|
await clearAllSyncPlanRecords(this.plugin.db);
|
||||||
|
new Notice("sync plans history (in db) deleted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncMappingDiv = containerEl.createEl("div");
|
||||||
|
syncMappingDiv.createEl("p", {
|
||||||
|
text: "Sync mappings history stores the actual LOCAL last modified time of the REMOTE objects.",
|
||||||
|
});
|
||||||
|
|
||||||
|
syncMappingDiv.createEl("p", {
|
||||||
|
text: "If the sync mappings history are deleted, unnecessary data exchanges may occur in next-time syncing, because whether a remote object and local object with same name are equivalent or not could not be determined correctly by comparing last modified times.",
|
||||||
|
});
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("delete sync mappings history in db")
|
||||||
|
.setDesc("delete sync mappings history in db")
|
||||||
|
.addButton(async (button) => {
|
||||||
|
button.setButtonText("Delete Sync Mappings");
|
||||||
|
button.onClick(async () => {
|
||||||
|
await clearAllSyncMetaMapping(this.plugin.db);
|
||||||
|
new Notice("sync mappings history (in local db) deleted");
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-4
@@ -1,11 +1,33 @@
|
|||||||
import { Vault } from "obsidian";
|
import { Vault } from "obsidian";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
|
import { base32 } from "rfc4648";
|
||||||
|
import XRegExp from "xregexp";
|
||||||
|
|
||||||
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "ftp";
|
export type SUPPORTED_SERVICES_TYPE = "s3" | "webdav" | "ftp";
|
||||||
|
|
||||||
export const ignoreHiddenFiles = (item: string) => {
|
/**
|
||||||
const basename = path.basename(item);
|
* If any part of the file starts with '.' or '_' then it's a hidden file.
|
||||||
return basename === "." || basename[0] !== ".";
|
* @param item
|
||||||
|
* @param loose
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const isHiddenPath = (item: string, loose: boolean = true) => {
|
||||||
|
const k = path.posix.normalize(item); // TODO: only unix path now
|
||||||
|
const k2 = k.split("/"); // TODO: only unix path now
|
||||||
|
// console.log(k2)
|
||||||
|
for (const singlePart of k2) {
|
||||||
|
if (singlePart === "." || singlePart === ".." || singlePart === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (singlePart[0] === ".") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (loose && singlePart[0] === "_") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,9 +53,12 @@ export const getFolderLevels = (x: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const mkdirpInVault = async (thePath: string, vault: Vault) => {
|
export const mkdirpInVault = async (thePath: string, vault: Vault) => {
|
||||||
|
// console.log(thePath);
|
||||||
const foldersToBuild = getFolderLevels(thePath);
|
const foldersToBuild = getFolderLevels(thePath);
|
||||||
|
// console.log(foldersToBuild);
|
||||||
for (const folder of foldersToBuild) {
|
for (const folder of foldersToBuild) {
|
||||||
const r = await vault.adapter.exists(folder);
|
const r = await vault.adapter.exists(folder);
|
||||||
|
// console.log(r);
|
||||||
if (!r) {
|
if (!r) {
|
||||||
console.log(`mkdir ${folder}`);
|
console.log(`mkdir ${folder}`);
|
||||||
await vault.adapter.mkdir(folder);
|
await vault.adapter.mkdir(folder);
|
||||||
@@ -46,7 +71,7 @@ export const mkdirpInVault = async (thePath: string, vault: Vault) => {
|
|||||||
* @param b Buffer
|
* @param b Buffer
|
||||||
* @returns ArrayBuffer
|
* @returns ArrayBuffer
|
||||||
*/
|
*/
|
||||||
export const bufferToArrayBuffer = (b: Buffer) => {
|
export const bufferToArrayBuffer = (b: Buffer | Uint8Array) => {
|
||||||
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
|
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,6 +88,45 @@ export const arrayBufferToBase64 = (b: ArrayBuffer) => {
|
|||||||
return arrayBufferToBuffer(b).toString("base64");
|
return arrayBufferToBuffer(b).toString("base64");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const arrayBufferToHex = (b: ArrayBuffer) => {
|
||||||
|
return arrayBufferToBuffer(b).toString("hex");
|
||||||
|
};
|
||||||
|
|
||||||
export const base64ToArrayBuffer = (b64text: string) => {
|
export const base64ToArrayBuffer = (b64text: string) => {
|
||||||
return bufferToArrayBuffer(Buffer.from(b64text, "base64"));
|
return bufferToArrayBuffer(Buffer.from(b64text, "base64"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://stackoverflow.com/questions/43131242
|
||||||
|
* @param hex
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const hexStringToTypedArray = (hex: string) => {
|
||||||
|
return new Uint8Array(
|
||||||
|
hex.match(/[\da-f]{2}/gi).map(function (h) {
|
||||||
|
return parseInt(h, 16);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const base64ToBase32 = (a: string) => {
|
||||||
|
return base32.stringify(Buffer.from(a, "base64"));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS Safari could decrypt string with invalid password!
|
||||||
|
* So we need an extra way to test the decrypted result.
|
||||||
|
* One simple way is testing the result are "valid", printable chars or not.
|
||||||
|
*
|
||||||
|
* https://stackoverflow.com/questions/6198986
|
||||||
|
* https://www.regular-expressions.info/unicode.html
|
||||||
|
* Manual test shows that emojis like '🍎' match '\\p{Cs}',
|
||||||
|
* so we need to write the regrex in a form that \p{C} minus \p{Cs}
|
||||||
|
* @param a
|
||||||
|
*/
|
||||||
|
export const isVaildText = (a: string) => {
|
||||||
|
// If the regex matches, the string is invalid.
|
||||||
|
return !XRegExp("\\p{Cc}|\\p{Cf}|\\p{Co}|\\p{Cn}|\\p{Zl}|\\p{Zp}", "A").test(
|
||||||
|
a
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Readable } from "stream";
|
|||||||
|
|
||||||
import { Vault } from "obsidian";
|
import { Vault } from "obsidian";
|
||||||
|
|
||||||
|
import { Upload } from "@aws-sdk/lib-storage";
|
||||||
import {
|
import {
|
||||||
S3Client,
|
S3Client,
|
||||||
ListObjectsV2Command,
|
ListObjectsV2Command,
|
||||||
@@ -10,6 +11,9 @@ import {
|
|||||||
GetObjectCommand,
|
GetObjectCommand,
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
ListObjectsV2CommandInput,
|
||||||
|
ListObjectsV2CommandOutput,
|
||||||
} from "@aws-sdk/client-s3";
|
} from "@aws-sdk/client-s3";
|
||||||
|
|
||||||
import type { _Object } from "@aws-sdk/client-s3";
|
import type { _Object } from "@aws-sdk/client-s3";
|
||||||
@@ -95,7 +99,7 @@ export const uploadToRemote = async (
|
|||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return await getRemoteMeta(s3Client, s3Config, fileOrFolderPath);
|
return await getRemoteMeta(s3Client, s3Config, uploadFile);
|
||||||
} else {
|
} else {
|
||||||
// file
|
// file
|
||||||
// we ignore isRecursively parameter here
|
// we ignore isRecursively parameter here
|
||||||
@@ -109,17 +113,27 @@ export const uploadToRemote = async (
|
|||||||
const localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
const localContent = await vault.adapter.readBinary(fileOrFolderPath);
|
||||||
let remoteContent = localContent;
|
let remoteContent = localContent;
|
||||||
if (password !== "") {
|
if (password !== "") {
|
||||||
remoteContent = encryptArrayBuffer(localContent, password);
|
remoteContent = await encryptArrayBuffer(localContent, password);
|
||||||
}
|
}
|
||||||
const body = arrayBufferToBuffer(remoteContent);
|
const body = arrayBufferToBuffer(remoteContent);
|
||||||
await s3Client.send(
|
|
||||||
new PutObjectCommand({
|
const upload = new Upload({
|
||||||
|
client: s3Client,
|
||||||
|
queueSize: 20, // concurrency
|
||||||
|
partSize: 5242880, // minimal 5MB by default
|
||||||
|
leavePartsOnError: false,
|
||||||
|
params: {
|
||||||
Bucket: s3Config.s3BucketName,
|
Bucket: s3Config.s3BucketName,
|
||||||
Key: uploadFile,
|
Key: uploadFile,
|
||||||
Body: body,
|
Body: body,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
})
|
},
|
||||||
);
|
});
|
||||||
|
upload.on("httpUploadProgress", (progress) => {
|
||||||
|
// console.log(progress);
|
||||||
|
});
|
||||||
|
await upload.done();
|
||||||
|
|
||||||
return await getRemoteMeta(s3Client, s3Config, uploadFile);
|
return await getRemoteMeta(s3Client, s3Config, uploadFile);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -129,17 +143,45 @@ export const listFromRemote = async (
|
|||||||
s3Config: S3Config,
|
s3Config: S3Config,
|
||||||
prefix?: string
|
prefix?: string
|
||||||
) => {
|
) => {
|
||||||
|
const confCmd = {
|
||||||
|
Bucket: s3Config.s3BucketName,
|
||||||
|
} as ListObjectsV2CommandInput;
|
||||||
if (prefix !== undefined) {
|
if (prefix !== undefined) {
|
||||||
return await s3Client.send(
|
confCmd.Prefix = prefix;
|
||||||
new ListObjectsV2Command({
|
|
||||||
Bucket: s3Config.s3BucketName,
|
|
||||||
Prefix: prefix,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return await s3Client.send(
|
|
||||||
new ListObjectsV2Command({ Bucket: s3Config.s3BucketName })
|
const contents = [] as _Object[];
|
||||||
);
|
|
||||||
|
let isTruncated = true;
|
||||||
|
let continuationToken = "";
|
||||||
|
do {
|
||||||
|
const rsp = await s3Client.send(new ListObjectsV2Command(confCmd));
|
||||||
|
|
||||||
|
if (rsp.$metadata.httpStatusCode !== 200) {
|
||||||
|
throw Error("some thing bad while listing remote!");
|
||||||
|
}
|
||||||
|
if (rsp.Contents === undefined) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
contents.push(...rsp.Contents);
|
||||||
|
|
||||||
|
isTruncated = rsp.IsTruncated;
|
||||||
|
confCmd.ContinuationToken = rsp.NextContinuationToken;
|
||||||
|
if (
|
||||||
|
isTruncated &&
|
||||||
|
(continuationToken === undefined || continuationToken === "")
|
||||||
|
) {
|
||||||
|
throw Error("isTruncated is true but no continuationToken provided");
|
||||||
|
}
|
||||||
|
} while (isTruncated);
|
||||||
|
|
||||||
|
// ensemble fake rsp
|
||||||
|
return {
|
||||||
|
"$.metadata": {
|
||||||
|
httpStatusCode: 200,
|
||||||
|
},
|
||||||
|
Contents: contents,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,7 +256,7 @@ export const downloadFromRemote = async (
|
|||||||
);
|
);
|
||||||
let localContent = remoteContent;
|
let localContent = remoteContent;
|
||||||
if (password !== "") {
|
if (password !== "") {
|
||||||
localContent = decryptArrayBuffer(remoteContent, password);
|
localContent = await decryptArrayBuffer(remoteContent, password);
|
||||||
}
|
}
|
||||||
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
await vault.adapter.writeBinary(fileOrFolderPath, localContent, {
|
||||||
mtime: mtime,
|
mtime: mtime,
|
||||||
@@ -266,3 +308,31 @@ export const deleteFromRemote = async (
|
|||||||
// pass
|
// pass
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check the config of S3 by heading bucket
|
||||||
|
* https://stackoverflow.com/questions/50842835
|
||||||
|
* @param s3Client
|
||||||
|
* @param s3Config
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const checkS3Connectivity = async (
|
||||||
|
s3Client: S3Client,
|
||||||
|
s3Config: S3Config
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const results = await s3Client.send(
|
||||||
|
new HeadBucketCommand({ Bucket: s3Config.s3BucketName })
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
results === undefined ||
|
||||||
|
results.$metadata === undefined ||
|
||||||
|
results.$metadata.httpStatusCode === undefined
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return results.$metadata.httpStatusCode === 200;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
+259
-106
@@ -16,8 +16,27 @@ import {
|
|||||||
deleteFromRemote,
|
deleteFromRemote,
|
||||||
downloadFromRemote,
|
downloadFromRemote,
|
||||||
} from "./s3";
|
} from "./s3";
|
||||||
import { mkdirpInVault } from "./misc";
|
import {
|
||||||
import { decryptBase32ToString, encryptStringToBase32 } from "./encrypt";
|
mkdirpInVault,
|
||||||
|
SUPPORTED_SERVICES_TYPE,
|
||||||
|
isHiddenPath,
|
||||||
|
isVaildText,
|
||||||
|
} from "./misc";
|
||||||
|
import {
|
||||||
|
decryptBase32ToString,
|
||||||
|
encryptStringToBase32,
|
||||||
|
MAGIC_ENCRYPTED_PREFIX_BASE32,
|
||||||
|
} from "./encrypt";
|
||||||
|
|
||||||
|
export type SyncStatusType =
|
||||||
|
| "idle"
|
||||||
|
| "preparing"
|
||||||
|
| "getting_remote_meta"
|
||||||
|
| "getting_local_meta"
|
||||||
|
| "checking_password"
|
||||||
|
| "generating_plan"
|
||||||
|
| "syncing"
|
||||||
|
| "finish";
|
||||||
|
|
||||||
type DecisionType =
|
type DecisionType =
|
||||||
| "undecided"
|
| "undecided"
|
||||||
@@ -31,8 +50,6 @@ type DecisionType =
|
|||||||
| "mkdirplocal"
|
| "mkdirplocal"
|
||||||
| "skip";
|
| "skip";
|
||||||
|
|
||||||
export type SyncStatusType = "idle" | "preparing" | "syncing";
|
|
||||||
|
|
||||||
interface FileOrFolderMixedState {
|
interface FileOrFolderMixedState {
|
||||||
key: string;
|
key: string;
|
||||||
exist_local?: boolean;
|
exist_local?: boolean;
|
||||||
@@ -48,7 +65,84 @@ interface FileOrFolderMixedState {
|
|||||||
remote_encrypted_key?: string;
|
remote_encrypted_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ensembleMixedStates = async (
|
export interface SyncPlanType {
|
||||||
|
ts: number;
|
||||||
|
remoteType: SUPPORTED_SERVICES_TYPE;
|
||||||
|
mixedStates: Record<string, FileOrFolderMixedState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordCheckType {
|
||||||
|
ok: boolean;
|
||||||
|
reason:
|
||||||
|
| "ok"
|
||||||
|
| "empty_remote"
|
||||||
|
| "remote_encrypted_local_no_password"
|
||||||
|
| "password_matched"
|
||||||
|
| "password_not_matched"
|
||||||
|
| "invalid_text_after_decryption"
|
||||||
|
| "remote_not_encrypted_local_has_password"
|
||||||
|
| "no_password_both_sides";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isPasswordOk = async (
|
||||||
|
remote: S3ObjectType[],
|
||||||
|
password: string = ""
|
||||||
|
) => {
|
||||||
|
if (remote === undefined || remote.length === 0) {
|
||||||
|
// remote empty
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "empty_remote",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
const santyCheckKey = remote[0].Key;
|
||||||
|
if (santyCheckKey.startsWith(MAGIC_ENCRYPTED_PREFIX_BASE32)) {
|
||||||
|
// this is encrypted!
|
||||||
|
// try to decrypt it using the provided password.
|
||||||
|
if (password === "") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "remote_encrypted_local_no_password",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await decryptBase32ToString(santyCheckKey, password);
|
||||||
|
|
||||||
|
// additional test
|
||||||
|
// because iOS Safari bypasses decryption with wrong password!
|
||||||
|
if (isVaildText(res)) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "password_matched",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "invalid_text_after_decryption",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "password_not_matched",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// it is not encrypted!
|
||||||
|
if (password !== "") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "remote_not_encrypted_local_has_password",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: "no_password_both_sides",
|
||||||
|
} as PasswordCheckType;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensembleMixedStates = async (
|
||||||
remote: S3ObjectType[],
|
remote: S3ObjectType[],
|
||||||
local: TAbstractFile[],
|
local: TAbstractFile[],
|
||||||
deleteHistory: FileFolderHistoryRecord[],
|
deleteHistory: FileFolderHistoryRecord[],
|
||||||
@@ -62,7 +156,7 @@ export const ensembleMixedStates = async (
|
|||||||
const remoteEncryptedKey = entry.Key;
|
const remoteEncryptedKey = entry.Key;
|
||||||
let key = remoteEncryptedKey;
|
let key = remoteEncryptedKey;
|
||||||
if (password !== "") {
|
if (password !== "") {
|
||||||
key = decryptBase32ToString(remoteEncryptedKey, password);
|
key = await decryptBase32ToString(remoteEncryptedKey, password);
|
||||||
}
|
}
|
||||||
const backwardMapping = await getSyncMetaMappingByRemoteKeyS3(
|
const backwardMapping = await getSyncMetaMappingByRemoteKeyS3(
|
||||||
db,
|
db,
|
||||||
@@ -90,6 +184,9 @@ export const ensembleMixedStates = async (
|
|||||||
remote_encrypted_key: remoteEncryptedKey,
|
remote_encrypted_key: remoteEncryptedKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (isHiddenPath(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (results.hasOwnProperty(key)) {
|
if (results.hasOwnProperty(key)) {
|
||||||
results[key].key = r.key;
|
results[key].key = r.key;
|
||||||
results[key].exist_remote = r.exist_remote;
|
results[key].exist_remote = r.exist_remote;
|
||||||
@@ -128,6 +225,9 @@ export const ensembleMixedStates = async (
|
|||||||
throw Error(`unexpected ${entry}`);
|
throw Error(`unexpected ${entry}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isHiddenPath(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (results.hasOwnProperty(key)) {
|
if (results.hasOwnProperty(key)) {
|
||||||
results[key].key = r.key;
|
results[key].key = r.key;
|
||||||
results[key].exist_local = r.exist_local;
|
results[key].exist_local = r.exist_local;
|
||||||
@@ -155,6 +255,9 @@ export const ensembleMixedStates = async (
|
|||||||
delete_time_local: entry.action_when,
|
delete_time_local: entry.action_when,
|
||||||
} as FileOrFolderMixedState;
|
} as FileOrFolderMixedState;
|
||||||
|
|
||||||
|
if (isHiddenPath(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (results.hasOwnProperty(key)) {
|
if (results.hasOwnProperty(key)) {
|
||||||
results[key].key = r.key;
|
results[key].key = r.key;
|
||||||
results[key].delete_time_local = r.delete_time_local;
|
results[key].delete_time_local = r.delete_time_local;
|
||||||
@@ -166,9 +269,10 @@ export const ensembleMixedStates = async (
|
|||||||
return results;
|
return results;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getOperation = (
|
const getOperation = (
|
||||||
origRecord: FileOrFolderMixedState,
|
origRecord: FileOrFolderMixedState,
|
||||||
inplace: boolean = false
|
inplace: boolean = false,
|
||||||
|
password: string = ""
|
||||||
) => {
|
) => {
|
||||||
let r = origRecord;
|
let r = origRecord;
|
||||||
if (!inplace) {
|
if (!inplace) {
|
||||||
@@ -216,6 +320,7 @@ export const getOperation = (
|
|||||||
r.mtime_remote !== undefined &&
|
r.mtime_remote !== undefined &&
|
||||||
r.mtime_local !== undefined &&
|
r.mtime_local !== undefined &&
|
||||||
r.mtime_remote === r.mtime_local &&
|
r.mtime_remote === r.mtime_local &&
|
||||||
|
password === "" &&
|
||||||
r.size_local === r.size_remote
|
r.size_local === r.size_remote
|
||||||
) {
|
) {
|
||||||
r.decision = "skip";
|
r.decision = "skip";
|
||||||
@@ -226,17 +331,31 @@ export const getOperation = (
|
|||||||
r.mtime_remote !== undefined &&
|
r.mtime_remote !== undefined &&
|
||||||
r.mtime_local !== undefined &&
|
r.mtime_local !== undefined &&
|
||||||
r.mtime_remote === r.mtime_local &&
|
r.mtime_remote === r.mtime_local &&
|
||||||
|
password === "" &&
|
||||||
r.size_local !== r.size_remote
|
r.size_local !== r.size_remote
|
||||||
) {
|
) {
|
||||||
r.decision = "upload_clearhist";
|
r.decision = "upload_clearhist";
|
||||||
r.decision_branch = 4;
|
r.decision_branch = 4;
|
||||||
|
} else if (
|
||||||
|
r.exist_remote &&
|
||||||
|
r.exist_local &&
|
||||||
|
r.mtime_remote !== undefined &&
|
||||||
|
r.mtime_local !== undefined &&
|
||||||
|
r.mtime_remote === r.mtime_local &&
|
||||||
|
password !== ""
|
||||||
|
) {
|
||||||
|
// if we have encryption,
|
||||||
|
// the size is always unequal
|
||||||
|
// only mtime(s) are reliable
|
||||||
|
r.decision = "skip";
|
||||||
|
r.decision_branch = 5;
|
||||||
} else if (r.exist_remote && r.exist_local && r.mtime_local === undefined) {
|
} else if (r.exist_remote && r.exist_local && r.mtime_local === undefined) {
|
||||||
// this must be a folder!
|
// this must be a folder!
|
||||||
if (!r.key.endsWith("/")) {
|
if (!r.key.endsWith("/")) {
|
||||||
throw Error(`${r.key} is not a folder but lacks local mtime`);
|
throw Error(`${r.key} is not a folder but lacks local mtime`);
|
||||||
}
|
}
|
||||||
r.decision = "skip";
|
r.decision = "skip";
|
||||||
r.decision_branch = 5;
|
r.decision_branch = 6;
|
||||||
} else if (
|
} else if (
|
||||||
r.exist_remote &&
|
r.exist_remote &&
|
||||||
!r.exist_local &&
|
!r.exist_local &&
|
||||||
@@ -246,7 +365,7 @@ export const getOperation = (
|
|||||||
r.mtime_remote >= r.delete_time_local
|
r.mtime_remote >= r.delete_time_local
|
||||||
) {
|
) {
|
||||||
r.decision = "download_clearhist";
|
r.decision = "download_clearhist";
|
||||||
r.decision_branch = 6;
|
r.decision_branch = 7;
|
||||||
} else if (
|
} else if (
|
||||||
r.exist_remote &&
|
r.exist_remote &&
|
||||||
!r.exist_local &&
|
!r.exist_local &&
|
||||||
@@ -256,7 +375,7 @@ export const getOperation = (
|
|||||||
r.mtime_remote < r.delete_time_local
|
r.mtime_remote < r.delete_time_local
|
||||||
) {
|
) {
|
||||||
r.decision = "delremote_clearhist";
|
r.decision = "delremote_clearhist";
|
||||||
r.decision_branch = 7;
|
r.decision_branch = 8;
|
||||||
} else if (
|
} else if (
|
||||||
r.exist_remote &&
|
r.exist_remote &&
|
||||||
!r.exist_local &&
|
!r.exist_local &&
|
||||||
@@ -265,10 +384,10 @@ export const getOperation = (
|
|||||||
r.delete_time_local == undefined
|
r.delete_time_local == undefined
|
||||||
) {
|
) {
|
||||||
r.decision = "download";
|
r.decision = "download";
|
||||||
r.decision_branch = 8;
|
r.decision_branch = 9;
|
||||||
} else if (!r.exist_remote && r.exist_local && r.mtime_remote === undefined) {
|
} else if (!r.exist_remote && r.exist_local && r.mtime_remote === undefined) {
|
||||||
r.decision = "upload_clearhist";
|
r.decision = "upload_clearhist";
|
||||||
r.decision_branch = 9;
|
r.decision_branch = 10;
|
||||||
} else if (
|
} else if (
|
||||||
!r.exist_remote &&
|
!r.exist_remote &&
|
||||||
!r.exist_local &&
|
!r.exist_local &&
|
||||||
@@ -276,111 +395,145 @@ export const getOperation = (
|
|||||||
r.mtime_local === undefined
|
r.mtime_local === undefined
|
||||||
) {
|
) {
|
||||||
r.decision = "clearhist";
|
r.decision = "clearhist";
|
||||||
r.decision_branch = 10;
|
r.decision_branch = 11;
|
||||||
}
|
}
|
||||||
|
|
||||||
return r;
|
return r;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getSyncPlan = async (
|
||||||
|
remote: S3ObjectType[],
|
||||||
|
local: TAbstractFile[],
|
||||||
|
deleteHistory: FileFolderHistoryRecord[],
|
||||||
|
db: lf.DatabaseConnection,
|
||||||
|
password: string = ""
|
||||||
|
) => {
|
||||||
|
const mixedStates = await ensembleMixedStates(
|
||||||
|
remote,
|
||||||
|
local,
|
||||||
|
deleteHistory,
|
||||||
|
db,
|
||||||
|
password
|
||||||
|
);
|
||||||
|
for (const [key, val] of Object.entries(mixedStates)) {
|
||||||
|
getOperation(val, true, password);
|
||||||
|
}
|
||||||
|
const plan = {
|
||||||
|
ts: Date.now(),
|
||||||
|
remoteType: "s3",
|
||||||
|
mixedStates: mixedStates,
|
||||||
|
} as SyncPlanType;
|
||||||
|
return plan;
|
||||||
|
};
|
||||||
|
|
||||||
export const doActualSync = async (
|
export const doActualSync = async (
|
||||||
s3Client: S3Client,
|
s3Client: S3Client,
|
||||||
s3Config: S3Config,
|
s3Config: S3Config,
|
||||||
db: lf.DatabaseConnection,
|
db: lf.DatabaseConnection,
|
||||||
vault: Vault,
|
vault: Vault,
|
||||||
keyStates: Record<string, FileOrFolderMixedState>,
|
syncPlan: SyncPlanType,
|
||||||
password: string = ""
|
password: string = ""
|
||||||
) => {
|
) => {
|
||||||
Object.entries(keyStates)
|
const keyStates = syncPlan.mixedStates;
|
||||||
.sort((k, v) => -(k as string).length)
|
await Promise.all(
|
||||||
.map(async ([k, v]) => {
|
Object.entries(keyStates)
|
||||||
const key = k as string;
|
.sort((k, v) => -(k as string).length)
|
||||||
const state = v as FileOrFolderMixedState;
|
.map(async ([k, v]) => {
|
||||||
let remoteEncryptedKey = key;
|
const key = k as string;
|
||||||
if (password !== "") {
|
const state = v as FileOrFolderMixedState;
|
||||||
remoteEncryptedKey = state.remote_encrypted_key;
|
let remoteEncryptedKey = key;
|
||||||
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
if (password !== "") {
|
||||||
remoteEncryptedKey = encryptStringToBase32(key, password);
|
remoteEncryptedKey = state.remote_encrypted_key;
|
||||||
|
if (remoteEncryptedKey === undefined || remoteEncryptedKey === "") {
|
||||||
|
remoteEncryptedKey = await encryptStringToBase32(key, password);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
state.decision === undefined ||
|
state.decision === undefined ||
|
||||||
state.decision === "unknown" ||
|
state.decision === "unknown" ||
|
||||||
state.decision === "undecided"
|
state.decision === "undecided"
|
||||||
) {
|
) {
|
||||||
throw Error(`unknown decision in ${JSON.stringify(state)}`);
|
throw Error(`unknown decision in ${JSON.stringify(state)}`);
|
||||||
} else if (state.decision === "skip") {
|
} else if (state.decision === "skip") {
|
||||||
// do nothing
|
// do nothing
|
||||||
} else if (state.decision === "download_clearhist") {
|
} else if (state.decision === "download_clearhist") {
|
||||||
await downloadFromRemote(
|
await downloadFromRemote(
|
||||||
s3Client,
|
s3Client,
|
||||||
s3Config,
|
s3Config,
|
||||||
state.key,
|
state.key,
|
||||||
vault,
|
vault,
|
||||||
state.mtime_remote,
|
state.mtime_remote,
|
||||||
password,
|
password,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey
|
||||||
);
|
);
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||||
} else if (state.decision === "upload_clearhist") {
|
} else if (state.decision === "upload_clearhist") {
|
||||||
const remoteObjMeta = await uploadToRemote(
|
const remoteObjMeta = await uploadToRemote(
|
||||||
s3Client,
|
s3Client,
|
||||||
s3Config,
|
s3Config,
|
||||||
state.key,
|
state.key,
|
||||||
vault,
|
vault,
|
||||||
false,
|
false,
|
||||||
password,
|
password,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey
|
||||||
);
|
);
|
||||||
await upsertSyncMetaMappingDataS3(
|
await upsertSyncMetaMappingDataS3(
|
||||||
db,
|
db,
|
||||||
state.key,
|
state.key,
|
||||||
state.mtime_local,
|
state.mtime_local,
|
||||||
state.size_local,
|
state.size_local,
|
||||||
state.key,
|
state.key,
|
||||||
remoteObjMeta.LastModified.valueOf(),
|
remoteObjMeta.LastModified.valueOf(),
|
||||||
remoteObjMeta.ContentLength,
|
remoteObjMeta.ContentLength,
|
||||||
remoteObjMeta.ETag
|
remoteObjMeta.ETag
|
||||||
);
|
);
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||||
} else if (state.decision === "download") {
|
} else if (state.decision === "download") {
|
||||||
await mkdirpInVault(state.key, vault);
|
await mkdirpInVault(state.key, vault);
|
||||||
await downloadFromRemote(
|
await downloadFromRemote(
|
||||||
s3Client,
|
s3Client,
|
||||||
s3Config,
|
s3Config,
|
||||||
state.key,
|
state.key,
|
||||||
vault,
|
vault,
|
||||||
state.mtime_remote,
|
state.mtime_remote,
|
||||||
password,
|
password,
|
||||||
remoteEncryptedKey
|
remoteEncryptedKey
|
||||||
);
|
);
|
||||||
} else if (state.decision === "delremote_clearhist") {
|
} else if (state.decision === "delremote_clearhist") {
|
||||||
await deleteFromRemote(s3Client, s3Config, state.key);
|
await deleteFromRemote(
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
s3Client,
|
||||||
} else if (state.decision === "upload") {
|
s3Config,
|
||||||
const remoteObjMeta = await uploadToRemote(
|
state.key,
|
||||||
s3Client,
|
password,
|
||||||
s3Config,
|
remoteEncryptedKey
|
||||||
state.key,
|
);
|
||||||
vault,
|
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||||
false,
|
} else if (state.decision === "upload") {
|
||||||
password,
|
const remoteObjMeta = await uploadToRemote(
|
||||||
remoteEncryptedKey
|
s3Client,
|
||||||
);
|
s3Config,
|
||||||
await upsertSyncMetaMappingDataS3(
|
state.key,
|
||||||
db,
|
vault,
|
||||||
state.key,
|
false,
|
||||||
state.mtime_local,
|
password,
|
||||||
state.size_local,
|
remoteEncryptedKey
|
||||||
state.key,
|
);
|
||||||
remoteObjMeta.LastModified.valueOf(),
|
await upsertSyncMetaMappingDataS3(
|
||||||
remoteObjMeta.ContentLength,
|
db,
|
||||||
remoteObjMeta.ETag
|
state.key,
|
||||||
);
|
state.mtime_local,
|
||||||
} else if (state.decision === "clearhist") {
|
state.size_local,
|
||||||
await clearDeleteRenameHistoryOfKey(db, state.key);
|
state.key,
|
||||||
} else {
|
remoteObjMeta.LastModified.valueOf(),
|
||||||
throw Error("this should never happen!");
|
remoteObjMeta.ContentLength,
|
||||||
}
|
remoteObjMeta.ETag
|
||||||
});
|
);
|
||||||
|
} else if (state.decision === "clearhist") {
|
||||||
|
await clearDeleteRenameHistoryOfKey(db, state.key);
|
||||||
|
} else {
|
||||||
|
throw Error("this should never happen!");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
/* set the styles */
|
/* set the styles */
|
||||||
|
|
||||||
|
.password_second_confirm {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import * as chai from "chai";
|
||||||
|
import chaiAsPromised from "chai-as-promised";
|
||||||
|
import { base64ToBase32, bufferToArrayBuffer } from "../src/misc";
|
||||||
|
import {
|
||||||
|
decryptArrayBuffer,
|
||||||
|
decryptBase32ToString,
|
||||||
|
encryptArrayBuffer,
|
||||||
|
encryptStringToBase32,
|
||||||
|
} from "../src/encrypt";
|
||||||
|
|
||||||
|
chai.use(chaiAsPromised);
|
||||||
|
const expect = chai.expect;
|
||||||
|
|
||||||
|
describe("Encryption tests", () => {
|
||||||
|
beforeEach(function () {
|
||||||
|
global.window = {
|
||||||
|
crypto: require("crypto").webcrypto,
|
||||||
|
} as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt string", async () => {
|
||||||
|
const k = "dkjdhkfhdkjgsdklxxd";
|
||||||
|
const password = "hey";
|
||||||
|
expect(await encryptStringToBase32(k, password)).to.not.equal(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should raise error using different password", async () => {
|
||||||
|
const k = "secret text";
|
||||||
|
const password = "hey";
|
||||||
|
const password2 = "hey2";
|
||||||
|
const enc = await encryptStringToBase32(k, password);
|
||||||
|
await expect(decryptBase32ToString(enc, password2)).to.be.rejected;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt and decrypt string and get the same result returned", async () => {
|
||||||
|
const k = "jfkkjkjbce7983ycdeknkkjckooAIUHIDIBIE((*BII)njD/d/dd/d/sjxhux";
|
||||||
|
const password = "hfiuibec989###oiu982bj1`";
|
||||||
|
const enc = await encryptStringToBase32(k, password);
|
||||||
|
// console.log(enc);
|
||||||
|
const dec = await decryptBase32ToString(enc, password);
|
||||||
|
// console.log(dec);
|
||||||
|
expect(dec).equal(k);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt text file and get the same result as openssl", async () => {
|
||||||
|
const fileContent = (
|
||||||
|
await fs.readFileSync(
|
||||||
|
path.join(__dirname, "static_assets", "sometext.txt")
|
||||||
|
)
|
||||||
|
).toString("utf-8");
|
||||||
|
const password = "somepassword";
|
||||||
|
const saltHex = "8302F586FAB491EC";
|
||||||
|
const enc = await encryptStringToBase32(
|
||||||
|
fileContent,
|
||||||
|
password,
|
||||||
|
undefined,
|
||||||
|
saltHex
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const opensslBase64Res =
|
||||||
|
"U2FsdGVkX1+DAvWG+rSR7MSa+yJav1zCE7SSXiBooqwI5Q+LMpIthpk/pXkLj+25";
|
||||||
|
// we output base32, so we need some transformation
|
||||||
|
const opensslBase32Res = base64ToBase32(opensslBase64Res);
|
||||||
|
|
||||||
|
expect(enc).equal(opensslBase32Res);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should encrypt binary file and get the same result as openssl", async () => {
|
||||||
|
const testFolder = path.join(__dirname, "static_assets", "mona_lisa");
|
||||||
|
const testFileName =
|
||||||
|
"1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg";
|
||||||
|
const fileArrBuf = bufferToArrayBuffer(
|
||||||
|
await fs.readFileSync(path.join(testFolder, testFileName))
|
||||||
|
);
|
||||||
|
const password = "somepassword";
|
||||||
|
const saltHex = "8302F586FAB491EC";
|
||||||
|
const enc = await encryptArrayBuffer(
|
||||||
|
fileArrBuf,
|
||||||
|
password,
|
||||||
|
undefined,
|
||||||
|
saltHex
|
||||||
|
);
|
||||||
|
const opensslArrBuf = bufferToArrayBuffer(
|
||||||
|
await fs.readFileSync(path.join(testFolder, testFileName + ".enc"))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Buffer.from(enc).equals(Buffer.from(opensslArrBuf))).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should descypt binary file and get the same result as openssl", async () => {
|
||||||
|
const testFolder = path.join(__dirname, "static_assets", "mona_lisa");
|
||||||
|
const testFileName =
|
||||||
|
"1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg";
|
||||||
|
const fileArrBuf = bufferToArrayBuffer(
|
||||||
|
await fs.readFileSync(path.join(testFolder, testFileName + ".enc"))
|
||||||
|
);
|
||||||
|
const password = "somepassword";
|
||||||
|
const dec = await decryptArrayBuffer(fileArrBuf, password);
|
||||||
|
const opensslArrBuf = bufferToArrayBuffer(
|
||||||
|
await fs.readFileSync(path.join(testFolder, testFileName))
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(Buffer.from(dec).equals(Buffer.from(opensslArrBuf))).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
import { expect } from "chai";
|
||||||
|
|
||||||
|
import * as misc from "../src/misc";
|
||||||
|
|
||||||
|
describe("Misc: hidden file", () => {
|
||||||
|
it("should find hidden file correctly", () => {
|
||||||
|
let item = "";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.false;
|
||||||
|
|
||||||
|
item = ".";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.false;
|
||||||
|
|
||||||
|
item = "..";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.false;
|
||||||
|
|
||||||
|
item = "/x/y/z/../././../a/b/c";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.false;
|
||||||
|
|
||||||
|
item = ".hidden";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.true;
|
||||||
|
|
||||||
|
item = "_hidden_loose";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.true;
|
||||||
|
expect(misc.isHiddenPath(item, false)).to.be.false;
|
||||||
|
|
||||||
|
item = "/sdd/_hidden_loose";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.true;
|
||||||
|
|
||||||
|
item = "what/../_hidden_loose/what/what/what";
|
||||||
|
expect(misc.isHiddenPath(item)).to.be.true;
|
||||||
|
|
||||||
|
item = "what/../_hidden_loose/what/what/what";
|
||||||
|
expect(misc.isHiddenPath(item, false)).to.be.false;
|
||||||
|
|
||||||
|
item = "what/../_hidden_loose/../.hidden/what/what/what";
|
||||||
|
expect(misc.isHiddenPath(item, false)).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Misc: get folder levels", () => {
|
||||||
|
it("should ignore empty path", () => {
|
||||||
|
const item = "";
|
||||||
|
expect(misc.getFolderLevels(item)).to.be.empty;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should ignore single file", () => {
|
||||||
|
const item = "xxx";
|
||||||
|
expect(misc.getFolderLevels(item)).to.be.empty;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect path ending with /", () => {
|
||||||
|
const item = "xxx/";
|
||||||
|
const res = ["xxx"];
|
||||||
|
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should correctly split folders and files", () => {
|
||||||
|
const item = "xxx/yyy/zzz.md";
|
||||||
|
const res = ["xxx", "xxx/yyy"];
|
||||||
|
expect(misc.getFolderLevels(item)).to.deep.equal(res);
|
||||||
|
|
||||||
|
const item2 = "xxx/yyy/zzz";
|
||||||
|
const res2 = ["xxx", "xxx/yyy"];
|
||||||
|
expect(misc.getFolderLevels(item2)).to.deep.equal(res2);
|
||||||
|
|
||||||
|
const item3 = "xxx/yyy/zzz/";
|
||||||
|
const res3 = ["xxx", "xxx/yyy", "xxx/yyy/zzz"];
|
||||||
|
expect(misc.getFolderLevels(item3)).to.deep.equal(res3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Misc: vaild file name tests", () => {
|
||||||
|
it("should treat no ascii correctly", async () => {
|
||||||
|
const x = misc.isVaildText("😄🍎 apple 苹果");
|
||||||
|
// console.log(x)
|
||||||
|
expect(x).to.be.true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find not-printable chars correctly", async () => {
|
||||||
|
const x = misc.isVaildText("😄🍎 apple 苹果\u0000");
|
||||||
|
// console.log(x)
|
||||||
|
expect(x).to.be.false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow spaces/slashes/...", async () => {
|
||||||
|
const x = misc.isVaildText("😄🍎 apple 苹果/-_=/\\*%^&@#$`");
|
||||||
|
expect(x).to.be.true;
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
The file 1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg is downloaded from Wikimedia Commons: https://commons.wikimedia.org/wiki/File:Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg .
|
||||||
|
|
||||||
|
Quoted from the web address: ... This photographic reproduction is therefore also considered to be in the public domain in the United States. In other jurisdictions, re-use of this content may be restricted ...
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b332c298be98be9968715952822431a051f9d07e67460cdcf91a5a3a305b5df1
|
||||||
|
size 1141550
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cf67abd791f1e230bce57dd47150f262424247cbefb8be16efcd57d13955576a
|
||||||
|
size 1141568
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# The encryption file is produced by the following command.
|
||||||
|
# A salt is explictly provided because we need reproducible output in tests.
|
||||||
|
openssl enc -p -aes-256-cbc -S 8302F586FAB491EC -pbkdf2 -iter 10000 -pass pass:somepassword -in '1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg' -out 1374px-Mona_Lisa,_by_Leonardo_da_Vinci,_from_C2RMF_retouched.jpg.enc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
A secret text 你好世界
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"0.0.4": "0.12.15"
|
"0.0.14": "0.12.15"
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-20
@@ -33,29 +33,29 @@ module.exports = {
|
|||||||
extensions: [".tsx", ".ts", ".js"],
|
extensions: [".tsx", ".ts", ".js"],
|
||||||
mainFields: ["browser", "module", "main"],
|
mainFields: ["browser", "module", "main"],
|
||||||
fallback: {
|
fallback: {
|
||||||
assert: require.resolve("assert"),
|
// assert: require.resolve("assert"),
|
||||||
buffer: require.resolve("buffer/"),
|
// buffer: require.resolve("buffer/"),
|
||||||
console: require.resolve("console-browserify"),
|
// console: require.resolve("console-browserify"),
|
||||||
constants: require.resolve("constants-browserify"),
|
// constants: require.resolve("constants-browserify"),
|
||||||
crypto: require.resolve("crypto-browserify"),
|
// crypto: require.resolve("crypto-browserify"),
|
||||||
domain: require.resolve("domain-browser"),
|
// domain: require.resolve("domain-browser"),
|
||||||
events: require.resolve("events"),
|
// events: require.resolve("events"),
|
||||||
http: require.resolve("stream-http"),
|
// http: require.resolve("stream-http"),
|
||||||
https: require.resolve("https-browserify"),
|
// https: require.resolve("https-browserify"),
|
||||||
os: require.resolve("os-browserify/browser"),
|
// os: require.resolve("os-browserify/browser"),
|
||||||
path: require.resolve("path-browserify"),
|
path: require.resolve("path-browserify"),
|
||||||
punycode: require.resolve("punycode"),
|
// punycode: require.resolve("punycode"),
|
||||||
process: require.resolve("process/browser"),
|
process: require.resolve("process/browser"),
|
||||||
querystring: require.resolve("querystring-es3"),
|
// querystring: require.resolve("querystring-es3"),
|
||||||
stream: require.resolve("stream-browserify"),
|
stream: require.resolve("stream-browserify"),
|
||||||
string_decoder: require.resolve("string_decoder"),
|
// string_decoder: require.resolve("string_decoder"),
|
||||||
sys: require.resolve("util"),
|
// sys: require.resolve("util"),
|
||||||
timers: require.resolve("timers-browserify"),
|
// timers: require.resolve("timers-browserify"),
|
||||||
tty: require.resolve("tty-browserify"),
|
// tty: require.resolve("tty-browserify"),
|
||||||
url: require.resolve("url"),
|
// url: require.resolve("url"),
|
||||||
util: require.resolve("util"),
|
// util: require.resolve("util"),
|
||||||
vm: require.resolve("vm-browserify"),
|
// vm: require.resolve("vm-browserify"),
|
||||||
zlib: require.resolve("browserify-zlib"),
|
// zlib: require.resolve("browserify-zlib"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
externals: {
|
externals: {
|
||||||
|
|||||||
Reference in New Issue
Block a user