First alpha

This commit is contained in:
Fabian 2019-01-27 21:29:33 +01:00
commit 313f5aee97
41 changed files with 10856 additions and 0 deletions

4
.gitignore vendored Executable file
View File

@ -0,0 +1,4 @@
.cache/
.vscode/
dist/
node_modules/

7
config.json Executable file
View File

@ -0,0 +1,7 @@
{
"secure_file_server": "http://192.168.178.129:3004/",
"auth_server": "https://auth.stamm.me",
"client_id": "cf699b12-2014-4a61-bb7d-a7e38b197350",
"permission": "5c1ed399dc361731340a49d4",
"callback_url": "http://localhost:8080"
}

8857
package-lock.json generated Executable file

File diff suppressed because it is too large Load Diff

51
package.json Executable file
View File

@ -0,0 +1,51 @@
{
"name": "@hibas123/securenotes2",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "webpack --mode=production",
"watch": "webpack --mode=development --watch",
"start": "webpack-dev-server --mode=production",
"start:prod": "webpack-dev-server --mode=production",
"start:dev": "webpack-dev-server --mode=development"
},
"author": "Fabian Stamm <dev@fabianstamm.de>",
"license": "MIT",
"dependencies": {
"@hibas123/secure-file-wrapper": "^2.3.1",
"aes-js": "^3.1.2",
"crypto-js": "^3.1.9-1",
"feather-icons": "^4.10.0",
"idb": "^3.0.2",
"js-sha256": "^0.9.0",
"js-sha512": "^0.8.0",
"lodash.clonedeep": "^4.5.0",
"mini.css": "^3.0.1",
"secure-file-wrapper": "git+https://git.stamm.me/OpenServer/OSSecureFileWrapper.git",
"uuid": "^3.3.2"
},
"devDependencies": {
"@types/lodash.clonedeep": "^4.5.4",
"@types/uuid": "^3.4.4",
"copy-webpack-plugin": "^4.6.0",
"css-loader": "^2.1.0",
"file-loader": "^3.0.1",
"html-webpack-plugin": "^3.2.0",
"mini-css-extract-plugin": "^0.5.0",
"node-sass": "^4.10.0",
"preact": "^8.3.1",
"preact-svg-loader": "^0.2.1",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"ts-loader": "^5.3.0",
"typescript": "^3.2.4",
"webpack": "^4.29.0",
"webpack-bundle-analyzer": "^3.0.3",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.10",
"webpack-visualizer-plugin": "^0.1.11",
"workbox-webpack-plugin": "^3.6.3",
"worker-loader": "^2.0.0"
}
}

6
public/config.json Executable file
View File

@ -0,0 +1,6 @@
{
"auth_server": "https://auth.stamm.me",
"client_id": "",
"permission": "",
"callback_url": ""
}

1
public/manifest.json Executable file
View File

@ -0,0 +1 @@
{}

7
src/components/AddButton.tsx Executable file
View File

@ -0,0 +1,7 @@
import { h } from "preact";
import "./add_button.scss";
import Plus from "feather-icons/dist/icons/plus.svg";
export default function AddButton({ onClick }: { onClick: () => void }) {
return <button class="add_button_button circular primary" onClick={() => onClick()}><Plus width={undefined} height={undefined} /></button>
}

6
src/components/App.tsx Executable file
View File

@ -0,0 +1,6 @@
import { Router } from "./Routing";
import { h } from "preact";
export default function App() {
return <Router></Router>
}

0
src/components/ContextMenu.tsx Executable file
View File

56
src/components/Routing.tsx Executable file
View File

@ -0,0 +1,56 @@
import { h, Component } from 'preact';
import Navigation from '../navigation';
import "./routing.scss"
export class Router extends Component<{}, { next?: JSX.Element, current: JSX.Element }> {
mounted: HTMLDivElement = undefined;
constructor(props) {
super(props);
this.onChange = this.onChange.bind(this);
this.state = { current: Navigation.page.page, next: undefined }
}
componentWillMount() {
Navigation.pageObservable.subscribe(this.onChange, true)
}
componentWillUnmount() {
Navigation.pageObservable.unsubscribe(this.onChange)
}
to = -1;
onChange([page]: JSX.Element[]) {
this.setState({ next: page, current: this.state.next || this.state.current });
if (this.to >= 0) {
clearTimeout(this.to)
this.to = -1;
}
}
render() {
let overlay;
if (this.state.next) {
overlay = <div class="transition_container transition_slidein" key={this.state.next.key} ref={(elm: HTMLDivElement) => {
let lst = () => {
if (this.state.next)
this.setState({ current: this.state.next, next: undefined }, () => {
if (this.mounted)
this.mounted.scrollTo({ top: 0 })
});
if (elm)
elm.removeEventListener("animationend", lst)
}
if (elm)
elm.addEventListener("animationend", lst)
}}>
{this.state.next}
</div>
}
return <div style="overflow:hidden">
<div class="transition_container" ref={elm => this.mounted = elm}>
{this.state.current}
</div>
{overlay}
</div>
}
}

15
src/components/add_button.scss Executable file
View File

@ -0,0 +1,15 @@
// .add_button_container {}
.add_button_button {
position: fixed;
bottom: 2rem;
right: 2rem;
width: 4rem;
height: 4rem;
padding: 0.75rem;
z-index: 16;
}
.add_button_button>svg {
height: 2.5rem;
width: 2.5rem;
}

25
src/components/demo.tsx Executable file
View File

@ -0,0 +1,25 @@
import { h } from "preact"
import { Page } from "../page";
export interface DemoProps {
state: any;
}
export default class DemoPage extends Page<DemoProps, {}> {
constructor(props: DemoProps) {
super(props);
}
render() {
let elms = [];
for (let i = 0; i < 1000; i++) {
elms.push(<li>Hallo {i}</li>);
}
return <div style={{ background: this.props.state.color, marginTop: "-12px", paddingTop: "12px" }} >
<h1>Hallo Welt</h1>
<ul>
{elms}
</ul>
</div>
}
}

View File

@ -0,0 +1,40 @@
import { h, Component } from 'preact';
import "./modal.scss"
import { Modal } from './Modal';
export class InputModal extends Component<{ title: string, fieldname: string, type: "text" | "password", onResult: (result) => void }, {}> {
input: HTMLInputElement;
rand: string;
constructor(props) {
super(props);
this.rand = Math.random().toString();
}
componentWillUnmount() {
if (this.input)
this.input.value = "";
}
render() {
return <Modal title={this.props.title} onClose={() => this.props.onResult(null)}>
<fieldset style="border:none;">
<label for={this.rand}>{this.props.fieldname}</label>
<input style="min-width: 85%" autofocus ref={elm => {
this.input = elm
if (this.input)
setTimeout(() => this.input.focus(), 0)
}} type={this.props.type} id={this.rand} placeholder={this.props.fieldname} onKeyDown={evt => {
if (evt.keyCode === 13) {
this.props.onResult(this.input.value)
}
}} />
<div style="text-align: right;">
<button class="primary" style="display: inline-block;" onClick={() => {
this.props.onResult(this.input.value);
}}>Enter</button>
</div>
</fieldset>
</Modal>
}
}

View File

@ -0,0 +1,9 @@
import { h, Component } from 'preact';
import "./modal.scss"
import { Modal } from './Modal';
export default function LoadingModal() {
return <Modal title="Loading" noClose>
<div class="spinner primary" style="height: 80px; width: 80px; margin: 3rem auto;"></div>
</Modal>
}

31
src/components/modals/Modal.tsx Executable file
View File

@ -0,0 +1,31 @@
import { h, Component } from 'preact';
import "./modal.scss"
export class Modal extends Component<{ title: string, onClose?: () => void, noClose?: boolean }, {}> {
constructor(props) {
super(props);
}
render() {
return <div class="modal_container" onClick={(evt) => {
let path = evt.composedPath();
if (!path.find(e => {
let res = false;
let s = (e as Element);
if (s) {
if (s.classList) {
res = s.classList.contains("card")
}
}
return res;
}))
if (this.props.onClose) this.props.onClose();
}}>
<div class="card" >
<h3 class="section">{this.props.title}</h3>
{this.props.children}
</div>
</div>
}
}

View File

@ -0,0 +1,39 @@
import { h, Component } from 'preact';
import "./modal.scss"
import { Modal } from './Modal';
export class YesNoModal extends Component<{ title: string, onResult: (result: boolean | undefined) => void }, {}> {
constructor(props) {
super(props);
this.onKeyDown = this.onKeyDown.bind(this);
}
componentWillMount() {
window.addEventListener("keydown", this.onKeyDown);
}
componentWillUnmount() {
window.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(evt: KeyboardEvent) {
if (evt.keyCode === 74 || evt.keyCode === 89) this.props.onResult(true)
else if (evt.keyCode === 78) this.props.onResult(false)
}
render() {
return <Modal title={this.props.title} onClose={() => this.props.onResult(undefined)}>
<fieldset style="border:none;">
<div style="text-align: right;">
<button class="primary" style="display: inline-block;" onClick={() => {
this.props.onResult(false);
}}>No</button>
<button class="primary" style="display: inline-block;" onClick={() => {
this.props.onResult(true);
}}>Yes</button>
</div>
</fieldset>
</Modal>
}
}

View File

@ -0,0 +1,19 @@
.modal_container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 32;
}
.modal_container>.card {
position: absolute;
left: 0;
right: 0;
top: 10%;
width: 80%;
max-width: 800px;
margin: auto;
}

View File

@ -0,0 +1,180 @@
import { h, Component } from "preact"
import Notes, { IVault, ViewNote, MessageType } from "../../../notes";
import Trash from "feather-icons/dist/icons/trash-2.svg"
import X from "feather-icons/dist/icons/x.svg"
import Save from "feather-icons/dist/icons/save.svg"
import Navigation from "../../../navigation";
import { YesNoModal } from "../../modals/YesNoModal";
import LoadingModal from "../../modals/LoadingModal";
const minRows = 3;
export default class EntryComponent extends Component<{ vault: Promise<IVault>, id: string | undefined }, { loading: boolean, title: string, changed: boolean, modal: JSX.Element | undefined }> {
old_text: string;
text: string = "";
vault: IVault;
lineHeight: number = 24;
note: ViewNote;
rows: number = minRows;
skip_save: boolean = false;
constructor(props) {
super(props);
this.state = { changed: false, title: "", modal: undefined, loading: true };
}
private toVault() {
history.back()
// Navigation.setPage("/vault", { id: this.vault.id }, { entry: "false" }, true);
}
async componentWillMount() {
try {
this.skip_save = false;
this.setState({ loading: true })
this.vault = await this.props.vault;
let note: ViewNote;
if (this.props.id)
note = await this.vault.getNote(this.props.id)
else
note = this.vault.newNote();
if (!note) {
Notes.messageObservableServer.send({ message: "Note not found!", type: MessageType.ERROR });
// this.toVault()
} else {
this.note = note;
this.text = note.__value;
let rows = this.getRows(this.text);
if (rows !== this.rows) {
this.rows = rows;
}
let [title] = this.text.split("\n", 1);
this.setState({ loading: false, title })
}
} catch (err) {
Notes.sendErrorMessage(err);
}
}
private async save() {
try {
if (this.state.changed) {
this.note.__value = this.text;
await this.vault.saveNote(this.note);
this.setState({ changed: false })
}
} catch (err) {
Notes.sendErrorMessage(err);
}
}
// async onKeypress(event) {
// event = event || window.event;
//
// }
componentWillUnmount() {
if (!this.skip_save)
this.save()
}
strToNr(value: string) {
let match = value.match(/\d/g)
return Number(match.join(""))
}
getRows(value: string) {
const lines = (value.match(/\r?\n/g) || '').length + 1
return Math.max(lines + 1, minRows);
}
textAreaChange(evt: KeyboardEvent) {
if (evt.keyCode === 17 || evt.keyCode === 27) return; //No character, so not relevant for this function
let target = (evt.target as HTMLTextAreaElement)
let value = target.value;
this.text = value;
if (!this.state.changed && this.textAreaKeyPress(evt)) this.setState({ changed: true })
let [title] = value.split("\n", 1);
if (title !== this.state.title)
this.setState({ title });
let rows = this.getRows(value);
if (rows !== this.rows) {
target.rows = rows;
this.rows = rows;
}
}
exitHandler() {
if (this.state.changed) {
let modal = <YesNoModal title="Really want to quit?" onResult={res => {
if (res === true) {
this.skip_save = true;
this.toVault();
}
this.setState({ modal: undefined });
}} />
this.setState({ modal })
} else
this.toVault()
}
textAreaKeyPress(evt: KeyboardEvent) {
if ((evt.keyCode === 83 || evt.keyCode === 13) && evt.ctrlKey) {
event.preventDefault()
this.save();
return false;
}
else if (evt.keyCode === 27) {
event.preventDefault();
// this.skip_save = true;
// this.toVault()
this.exitHandler();
return false;
}
return true;
}
render() {
let loading_modal = this.state.loading ? <LoadingModal /> : undefined;
const save_handler = async () => {
await this.save()
Navigation.setPage("/vault", { id: this.vault.id }, { entry: "false" }, true);
this.toVault()
}
const delete_handler = async () => {
await this.vault.deleteNote(this.props.id);
this.toVault()
}
return <div>
{loading_modal}
{this.state.modal}
<header>
<div>
<a class="button header_icon_button" onClick={() => this.exitHandler()}><X height={undefined} width={undefined} /></a>
{this.state.changed ? <a class="button header_icon_button" onClick={() => save_handler()}><Save height={undefined} width={undefined} /></a> : undefined}
</div>
<h1 style="display:inline" class="button header_title">{this.state.title}</h1>
<a class="button header_icon_button" onClick={() => delete_handler()}><Trash height={undefined} width={undefined} /></a>
</header>
<div class="container">
<div class="row">
<div class="col-sm-12 col-md-8 col-lg-6 col-md-offset-2 col-lg-offset-3">
<textarea autofocus value={this.text} rows={this.rows} class="doc" style="width:100%;" onKeyDown={evt => this.textAreaKeyPress(evt)} onKeyUp={evt => this.textAreaChange(evt)} ref={elm => {
if (elm)
setTimeout(() => elm.focus(), 0)
}} />
</div>
</div>
</div>
</div>
}
}

View File

@ -0,0 +1,57 @@
import { h, Component } from "preact"
import { IVault, BaseNote } from "../../../notes";
import AddButton from "../../AddButton";
import Navigation from "../../../navigation";
import ArrowLeft from "feather-icons/dist/icons/arrow-left.svg"
import MoreVertival from "feather-icons/dist/icons/more-vertical.svg"
export default class EntryList extends Component<{ vault: Promise<IVault> }, { entries: BaseNote[] }> {
constructor(props) {
super(props)
this.state = { entries: [] }
}
vault: IVault;
async componentWillMount() {
this.vault = await this.props.vault;
this.vault.getAllNotes().then(entries => this.setState({ entries }))
}
render() {
const open_entry = (id: string | null) => {
Navigation.setPage("/vault", { id: this.vault.id }, { id, entry: "true" })
}
let elms = this.state.entries.map(entry => {
let [first, second] = entry.preview.split("\n", 2);
return <div class="vault_vault" onClick={() => {
open_entry(entry._id)
}}>
<span>{first}</span><br />
<span>{second}</span>
</div>
})
return <div>
<header>
<div>
<a class="button header_icon_button" onClick={() => history.back()}><ArrowLeft height={undefined} width={undefined} /></a>
</div>
<h1 style="display:inline" class="button header_title" onClick={() => Navigation.setPage("/")}>{this.vault ? this.vault.name : ""}</h1>
<a class="button header_icon_button"><MoreVertival height={undefined} width={undefined} /></a>
</header>
<AddButton onClick={() => open_entry(null)} />
<div class="container">
<div class="row">
<div class="col-sm-12 col-md-8 col-lg-6 col-md-offset-2 col-lg-offset-3">
<div class="card fluid">
<h1 class="section double-padded">Notes: </h1>
<div class="section">
{elms}
</div>
</div>
</div>
</div>
</div>
</div>
}
}

View File

@ -0,0 +1,37 @@
import { h } from "preact"
import { Page } from "../../../page";
import Notes, { IVault, BaseNote } from "../../../notes";
import Navigation from "../../../navigation";
import EntryComponent from "./Entry";
import EntryList from "./EntryList";
import "./vault.scss"
export interface VaultProps {
state: { id: string };
hidden: { entry: string, id: string };
}
export default class VaultPage extends Page<VaultProps, { entries: BaseNote[] }> {
vault: Promise<IVault>
constructor(props: VaultProps) {
super(props);
this.state = { entries: [] };
}
async componentWillMount() {
this.vault = Notes.getVault(this.props.state.id, Notes.getVaultKey(this.props.state.id))
this.vault.catch(err => {
Navigation.setPage("/")
})
}
render() {
if (this.props.hidden && this.props.hidden.entry === "true") {
return <EntryComponent vault={this.vault} id={this.props.hidden.id} />
} else {
return <EntryList vault={this.vault} />
}
}
}

View File

@ -0,0 +1,27 @@
.vault_vault>span:nth-of-type(1) {
font-size: 1.3rem;
margin-left: 0;
}
.vault_vault>span {
font-size: 1rem;
margin-left: 1rem;
}
.vault_vault {
padding: 0.5rem;
border-bottom: solid 1px var(--fore-color);
}
.vault_vault:hover {
background: var(--nav-hover-back-color);
}
.vault_vault>svg {
height: 2rem;
margin-right: 1rem;
}
.vault_vault:last-child {
border-bottom: none;
}

View File

@ -0,0 +1,126 @@
import { h } from "preact"
import { Page } from "../../../page";
import Notes, { VaultList } from "../../../notes";
import "./vaults.scss"
import Lock from "feather-icons/dist/icons/lock.svg";
import Unlock from "feather-icons/dist/icons/unlock.svg";
import Navigation from "../../../navigation";
import { InputModal } from "../../modals/InputModal";
import { YesNoModal } from "../../modals/YesNoModal";
import AddButton from "../../AddButton";
export interface VaultsProps {
state: any;
}
export default class VaultsPage extends Page<VaultsProps, { vaults: VaultList, modal: JSX.Element | undefined }> {
constructor(props: VaultsProps) {
super(props);
this.state = { vaults: [], modal: undefined };
}
updateVaults() {
Notes.getVaults().then(vaults => this.setState({ vaults }))
}
componentWillMount() {
this.updateVaults()
}
render() {
let elms = this.state.vaults.map(vault => {
return <div class="vaults_vault" onClick={() => {
const open = () => {
Navigation.setPage("/vault", { id: vault.id })
}
if (vault.encrypted) {
let key = Notes.getVaultKey(vault.id);
if (key) open()
else {
let modal = <InputModal title={"Enter password for " + vault.name} type="password" fieldname="Password" onResult={(value) => {
if (value === null) this.setState({ modal: undefined });
else {
let key = Notes.passwordToKey(value);
Notes.getVault(vault.id, key).then(() => {
let modal = <YesNoModal title="Save permanent?" onResult={(res) => {
if (res === undefined)
this.setState({ modal: undefined });
else {
this.setState({ modal: undefined });
Notes.saveVaultKey(vault.id, key, res);
open()
}
}} />
this.setState({ modal })
}).catch(err => {
alert("Invalid password!")
})
}
}} />
this.setState({ modal })
}
} else {
open()
}
}}>
{vault.encrypted ? <Lock height={undefined} width={undefined} /> : <Unlock height={undefined} width={undefined} />}
<span>
{vault.name}
</span>
</div>
})
return <div style={{ marginTop: "-12px", paddingTop: "12px" }} >
{this.state.modal}
<AddButton onClick={() => {
let modal = <InputModal title="Enter new name" fieldname="Name" type="text" onResult={(name) => {
const create = async (password?: string) => {
let key;
if (password) {
key = Notes.passwordToKey(password)
}
await Notes.createVault(name, key)
this.updateVaults();
}
if (name === null) this.setState({ modal: undefined })
else {
let modal = <YesNoModal title="Encrypt?" onResult={(encrypted) => {
if (encrypted === null) this.setState({ modal: undefined })
if (encrypted) {
let modal = <InputModal title="Enter new password" fieldname="Password" type="password" onResult={(password) => {
if (password === null) this.setState({ modal: undefined })
else {
create(password)
this.setState({ modal: undefined });
}
}} />
this.setState({ modal })
} else {
create()
this.setState({ modal: undefined });
}
}} />
this.setState({ modal })
}
}} />
this.setState({ modal })
}} />
<div class="container">
<div class="row">
<div class="col-sm-12 col-md-8 col-lg-6 col-md-offset-2 col-lg-offset-3">
<div class="card fluid">
<h1 class="section double-padded">Your vaults:</h1>
<div class="section">
{elms}
</div>
</div>
</div>
</div>
</div>
</div>
}
}

View File

@ -0,0 +1,21 @@
.vaults_vault>span {
font-size: 2rem !important;
}
.vaults_vault {
padding: 0.5rem;
border-bottom: solid 1px var(--fore-color);
}
.vaults_vault:hover {
background: var(--nav-hover-back-color);
}
.vaults_vault>svg {
height: 2rem;
margin-right: 1rem;
}
.vaults_vault:last-child {
border-bottom: none;
}

24
src/components/routing.scss Executable file
View File

@ -0,0 +1,24 @@
.transition_container {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow: auto;
background: var(--back-color);
}
.transition_slidein {
animation-name: slidein;
animation-duration: 0.3s;
z-index: 128;
}
@keyframes slidein {
from {
left: 100%;
}
to {
left: 0%;
}
}

55
src/helper/base64.ts Executable file
View File

@ -0,0 +1,55 @@
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// Use a lookup table to find the index.
var lookup = new Uint8Array(256);
for (var i = 0; i < chars.length; i++) {
lookup[chars.charCodeAt(i)] = i;
}
export function encode(arraybuffer: ArrayBuffer | Uint8Array) {
var bytes = new Uint8Array(arraybuffer),
i, len = bytes.length, base64 = "";
for (i = 0; i < len; i += 3) {
base64 += chars[bytes[i] >> 2];
base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)];
base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)];
base64 += chars[bytes[i + 2] & 63];
}
if ((len % 3) === 2) {
base64 = base64.substring(0, base64.length - 1) + "=";
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2) + "==";
}
return base64;
};
export function decode(base64: string) {
var bufferLength = base64.length * 0.75,
len = base64.length, i, p = 0,
encoded1, encoded2, encoded3, encoded4;
if (base64[base64.length - 1] === "=") {
bufferLength--;
if (base64[base64.length - 2] === "=") {
bufferLength--;
}
}
var arraybuffer = new ArrayBuffer(bufferLength),
bytes = new Uint8Array(arraybuffer);
for (i = 0; i < len; i += 4) {
encoded1 = lookup[base64.charCodeAt(i)];
encoded2 = lookup[base64.charCodeAt(i + 1)];
encoded3 = lookup[base64.charCodeAt(i + 2)];
encoded4 = lookup[base64.charCodeAt(i + 3)];
bytes[p++] = (encoded1 << 2) | (encoded2 >> 4);
bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2);
bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63);
}
return bytes;
}

104
src/helper/indexeddb.ts Executable file
View File

@ -0,0 +1,104 @@
import Lock from "./lock";
import { DB, openDb, Transaction } from "idb";
export default class IDB {
initLock = new Lock();
db: DB;
constructor(database: string, private stores: string[]) {
let lock = this.initLock.getLock();
lock.then(async l => {
let v = localStorage.getItem(database + "_version");
let version = 0;
if (v) version = Number(v)
let lastStoresS = localStorage.getItem(database + "_stores");
if (!lastStoresS) lastStoresS = "";
let lastStores = lastStoresS.split(",").filter(e => e !== "");
if (!stores.every(e => lastStores.indexOf(e) >= 0) || !lastStores.every(e => stores.indexOf(e) >= 0)) version++;
localStorage.setItem(database + "_version", version.toString());
localStorage.setItem(database + "_stores", stores.join(","));
this.db = await openDb(database, version, db => {
console.log("IndexedDB need update")
stores.forEach(store => {
if (!db.objectStoreNames.contains(store))
db.createObjectStore(store);
})
})
console.log("Got DATABASE", this.db)
l.release();
})
}
transaction(...stores: string[] | { name: string }[]) {
if (stores.length < 1) stores = this.stores;
let s: string[];
if (typeof stores[0] === "string")
s = <any>stores
else
s = (<any>stores).map(e => e.name)
return this.db.transaction(s, "readwrite")
}
getStore<T = any>(name: string) {
return {
name: name,
transaction: () => {
return this.db.transaction(name, "readwrite")
},
get: async (key: string, transaction?: Transaction): Promise<T> => {
(await this.initLock.getLock()).release()
return (transaction || this.db.transaction(name))
.objectStore(name).get(key);
},
getAll: async (transaction?: Transaction): Promise<T[]> => {
(await this.initLock.getLock()).release()
return (transaction || this.db.transaction(name))
.objectStore(name).getAll();
},
set: async (key: string, val: T, transaction?: Transaction) => {
(await this.initLock.getLock()).release()
const tx = (transaction || this.db.transaction(name, "readwrite"));
tx.objectStore(name).put(val, key);
return tx.complete;
},
delete: async (key: string, transaction?: Transaction) => {
(await this.initLock.getLock()).release()
const tx = (transaction || this.db.transaction(name, "readwrite"));
tx.objectStore(name).delete(key);
return tx.complete;
},
clear: async (transaction?: Transaction) => {
(await this.initLock.getLock()).release()
const tx = (transaction || this.db.transaction(name, "readwrite"));
tx.objectStore(name).clear();
return tx.complete;
},
keys: async (transaction?: Transaction): Promise<string[]> => {
(await this.initLock.getLock()).release()
const tx = (transaction || this.db.transaction(name));
const keys: string[] = [];
const store = tx.objectStore(name);
// This would be store.getAllKeys(), but it isn't supported by Edge or Safari.
// openKeyCursor isn't supported by Safari, so we fall back
(store.iterateKeyCursor || store.iterateCursor).call(store, (cursor: any) => {
if (!cursor) return;
keys.push(cursor.key);
cursor.continue();
});
return tx.complete.then(() => keys);
}
}
}
}

36
src/helper/lock.ts Executable file
View File

@ -0,0 +1,36 @@
export type Release = { release: () => void };
export default class Lock {
private _locked: boolean = false;
get locked() {
return this._locked;
}
private toCome: (() => void)[] = [];
constructor() {
this.release = this.release.bind(this);
}
async getLock(): Promise<Release> {
if (!this._locked) return { release: this.lock() };
else {
return new Promise<Release>((resolve) => {
this.toCome.push(() => {
resolve({ release: this.lock() });
})
})
}
}
private lock() {
this._locked = true;
return this.release;
}
private async release() {
if (this.toCome.length > 0) {
this.toCome.shift()();
} else {
this._locked = false;
}
}
}

46
src/helper/observable.ts Executable file
View File

@ -0,0 +1,46 @@
export type ObserverCallback<T> = (data: T[]) => void;
export default class Observable<T = any> {
private subscriber: { callback: ObserverCallback<T>, one: boolean }[] = [];
private events: T[] = [];
private timeout = undefined;
constructor(private collect: boolean = true, private collect_intervall: number = 100) { }
getPublicApi() {
return {
subscribe: (callback: ObserverCallback<T>, one: boolean = false) => {
let oldcb = this.subscriber.find(e => e.callback === callback);
if (oldcb)
oldcb.one = one
else
this.subscriber.push({ callback, one })
},
unsubscribe: (callback: ObserverCallback<T>) => {
let idx = this.subscriber.findIndex(e => e.callback === callback);
if (idx >= 0) {
this.subscriber.splice(idx, 1);
}
}
}
}
send(data: T) {
if (!this.collect)
this.subscriber.forEach(e => e.callback([data]));
else {
this.events.push(data);
if (!this.timeout) {
this.timeout = setTimeout(() => {
this.subscriber.forEach(cb => {
if (cb.one)
this.events.forEach(e => cb.callback([e]));
else
cb.callback(this.events)
});
this.timeout = 0;
}, this.collect_intervall);
}
}
}
}

77
src/helper/swipe.tsx Executable file
View File

@ -0,0 +1,77 @@
import { Component, h, cloneElement } from 'preact';
export default class SwipeRecognizer extends Component<{ onSwipe?: (direction: string) => void }, { swipe: string }> {
private tolerance = 100;
private gesture = { x: [], y: [], match: '' };
componentDidMount() {
this.base.addEventListener('touchstart', this.capture);
this.base.addEventListener('touchmove', this.capture);
this.base.addEventListener('touchend', this.compute)
}
componentWillUnmount() {
this.base.removeEventListener('touchstart', this.capture);
this.base.removeEventListener('touchmove', this.capture);
this.base.removeEventListener('touchend', this.compute);
}
private capture = (event: TouchEvent) => {
// event.preventDefault()
this.gesture.x.push(event.touches[0].clientX)
this.gesture.y.push(event.touches[0].clientY)
};
private compute = (event: TouchEvent) => {
// event.preventDefault();
let xStart = this.gesture.x[0];
let yStart = this.gesture.y[0];
let xEnd = this.gesture.x.pop();
let yEnd = this.gesture.y.pop();
let xTravel = xEnd - xStart;
let yTravel = yEnd - yStart;
console.log(xTravel, yTravel);
let xTravel_b = xTravel < 0 ? xTravel * (-1) : xTravel;
let yTravel_b = yTravel < 0 ? yTravel * (-1) : yTravel;
console.log(xTravel_b, yTravel_b);
if (xTravel_b > yTravel_b) {
if (xTravel_b > this.tolerance) {
if (xTravel > 0) {
this.gesture.match = "right";
} else {
this.gesture.match = "left";
}
}
} else {
if (yTravel_b > this.tolerance) {
if (yTravel > 0) {
this.gesture.match = "down";
} else {
this.gesture.match = "up";
}
}
}
console.log(this.gesture.match);
if (this.gesture.match !== '') {
this.onSwipe(this.gesture.match);
}
this.gesture.x = []
this.gesture.y = []
this.gesture.match = '';
};
onSwipe = (direction: string) => {
if (this.props.onSwipe) {
this.props.onSwipe(direction);
}
this.setState({ swipe: direction });
};
render({ children }, state) {
return cloneElement(children[0], state);
}
}

44
src/index.html Executable file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>SecureNotes</title>
<meta charset="utf8" />
<meta name="Description" content="Notes app">
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="manifest" href="/public/manifest.json">
<!-- <link rel="shortcut icon" href="/public/icon-72x72.png"> -->
<!-- Add to home screen for Safari on iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="eBook">
<link rel="apple-touch-icon" sizes="180x180" href="/public/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/public/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/public/favicon-16x16.png">
<link rel="manifest" href="/public/site.webmanifest">
<link rel="mask-icon" href="/public/safari-pinned-tab.svg" color="#3E9AE9">
<meta name="msapplication-TileColor" content="#3E9AE9">
<meta name="theme-color" content="#3E9AE9">
<!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/mini.css/3.0.1/mini-default.min.css"> -->
</head>
<body>
<noscript>
You have to enable JavaScript to use this site!
</noscript>
<div id="app"></div>
<script>
// Check that service workers are registered
// if ('serviceWorker' in navigator) {
// // Use the window load event to keep the page load performant
// window.addEventListener('load', () => {
// navigator.serviceWorker.register('/sw.js');
// });
// }
</script>
</body>
</html>

44
src/index.scss Executable file
View File

@ -0,0 +1,44 @@
@import url('https://fonts.googleapis.com/css?family=Roboto');
@import "./vars.scss";
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', sans-serif; // font-family: Verdana, Geneva, sans-serif;
font-size: 18px;
width: 100%;
height: 100%;
}
html {
min-height: 100%;
display: flex;
}
// .feather {
// width: 24px;
// height: 24px;
// stroke: currentColor;
// stroke-width: 2;
// stroke-linecap: round;
// stroke-linejoin: round;
// fill: none;
// }
.header_icon_button {
height: $header_icon_width;
width: $header_icon_width;
}
.header_title {
max-width: calc(100% - #{$header_icon_width} - #{$header_icon_width});
overflow: hidden;
}
header {
display: flex;
justify-content: space-between;
margin-bottom: calc(1.5 * var(--universal-padding));
}

43
src/index.tsx Executable file
View File

@ -0,0 +1,43 @@
import { h, render } from 'preact';
import App from './components/App';
// import "mini.css/src/flavors/mini-dark.scss"
import "mini.css/src/flavors/mini-default.scss"
import "./index.scss"
import Navigation from './navigation';
import VaultsPage from './components/routes/vaults/Vaults';
import { Page } from './page';
import Notes from "./notes"
import DemoPage from './components/demo';
import VaultPage from './components/routes/vault/Vault';
console.log(Notes);
(async () => {
// Initialize notes provider
if (Notes.loginRequired()) {
let url = new URL(location.href)
let code = url.searchParams.get("code");
if (code) {
let err = await Notes.getToken(code)
if (err) {
alert("Login failed: " + err)
Notes.login()
} else {
window.history.replaceState(null, document.title, "/" + window.location.hash);
}
} else {
Notes.login()
}
}
await Notes.start();
Navigation.default = VaultsPage as typeof Page;
Navigation.addPage("/vault", VaultPage as typeof Page)
Navigation.addPage("/demo", DemoPage as typeof Page)
Navigation.start();
render(<App />, document.body, document.getElementById('app'));
})()

112
src/navigation.ts Executable file
View File

@ -0,0 +1,112 @@
import Observable from "./helper/observable";
import { Page, PageProps } from "./page";
import { h } from "preact";
function serializQuery(obj: any) {
var str = [];
for (var p in obj)
if (obj.hasOwnProperty(p)) {
str.push(encodeURIComponent(p) + "=" + encodeURIComponent(obj[p]));
}
return str.join("&");
}
function parseQuery(query: string) {
let data: any = {};
if (query.startsWith("?")) query = query.slice(1)
query.split("&").forEach(e => {
let [key, value] = e.split("=");
key = decodeURIComponent(key)
value = decodeURIComponent(value)
data[key] = value
})
return data
}
export default class Navigation {
private static _pages: Map<string, typeof Page> = new Map();
private static _page: { route: string, page: JSX.Element };
private static pageObservableServer = new Observable<JSX.Element>(false);
public static pageObservable = Navigation.pageObservableServer.getPublicApi();
private static _state: { [key: string]: any };
private static _hidden_state: { [key: string]: any };
public static addPage(route: string, comp: typeof Page) {
Navigation._pages.set(route, comp);
}
// public static get state() {
// return Navigation._state;
// }
public static set default(comp: typeof Page) {
console.log("Set default");
Navigation._pages.set("/", comp);
}
public static set not_found(comp: typeof Page) {
Navigation._pages.set("/404", comp);
}
public static setPage(route: string, state?: { [key: string]: string }, hidden?: { [key: string]: string }, replace?: boolean) {
let component = Navigation._pages.get(route);
if (!component && route !== "/404") {
Navigation.setPage("/404", { route, key: "404" })
} else {
if (!Navigation.page || Navigation.page.route !== route || JSON.stringify(state) !== JSON.stringify(Navigation._state) || JSON.stringify(Navigation._hidden_state) !== JSON.stringify(hidden)) {
let s = "";
if (state) {
s = "?" + serializQuery(state)
}
let newhash = "#" + route + s;
let newkey = newhash + serializQuery(hidden)
let whash = window.location.hash;
if (!whash || whash === "") whash = "#/"
let oldkey = whash + serializQuery(history.state);
if (newkey !== oldkey) {
if (replace)
window.history.replaceState(hidden, document.title, newhash);
else
window.history.pushState(hidden, document.title, newhash);
}
let page = h(component as any, { state: state, key: newhash + serializQuery(hidden), hidden: hidden });
Navigation._state = state;
Navigation._hidden_state = hidden;
Navigation._page = { page, route };
Navigation.pageObservableServer.send(page);
}
}
}
static get page() {
return Navigation._page;
}
static onHashChange(hidden_state: { [key: string]: string }) {
let hash = window.location.hash.substring(1);
if (hash && hash !== "") {
let [route, state] = hash.split("?");
let s;
if (state) {
try {
s = parseQuery(state)
} catch (err) {
s = undefined;
console.error(err);
}
}
Navigation.setPage(route, s, hidden_state)
} else {
Navigation.setPage("/");
}
}
static start() {
window.addEventListener("popstate", (ev) => {
Navigation.onHashChange(ev.state);
})
Navigation.onHashChange(undefined);
}
}

531
src/notes.ts Executable file
View File

@ -0,0 +1,531 @@
import Observable from "./helper/observable";
import * as config from "../config.json"
import Lock from "./helper/lock"
import SecureFile, { IFile } from "@hibas123/secure-file-wrapper";
import * as b64 from "./helper/base64"
export class HttpError extends Error {
constructor(public status: number, public statusText: string) {
super(statusText);
console.log(statusText);
}
}
export enum MessageType {
INFO,
WARNING,
ERROR
}
// const newSymbol = Symbol("Symbol for new Notes")
export interface Note {
_id: string;
folder: string;
time: Date;
}
interface DBNote extends Note {
__value: Uint8Array;
preview: Uint8Array;
}
export interface BaseNote extends Note {
preview: string;
}
export interface ViewNote extends BaseNote {
__value: string;
}
// import * as AES from "crypto-js/aes"
import * as aesjs from "aes-js";
// import { } from "js-sha512"
import { sha256 } from "js-sha256"
import clonedeep = require("lodash.clonedeep");
import * as uuidv4 from "uuid/v4"
import IDB from "./helper/indexeddb";
import { Transaction } from "idb";
console.log(aesjs)
const Encoder = new TextEncoder();
const Decoder = new TextDecoder();
enum OpLogType {
CREATE,
CHANGE,
DELETE
}
interface OpLog {
/**
* Type of operation
*/
type: OpLogType;
/**
* The value
*/
values: {
value: Uint8Array,
preview: Uint8Array
};
/**
* Date of change
*/
date: Date;
}
export type VaultList = { name: string, encrypted: boolean, id: string }[];
export interface IVault {
name: string;
id: string;
encrypted: boolean;
getAllNotes(): Promise<BaseNote[]>;
searchNote(term: string): Promise<BaseNote[]>
newNote(): ViewNote;
saveNote(note: ViewNote): Promise<void>;
getNote(id: string): Promise<ViewNote>;
deleteNote(id: string): Promise<void>;
}
class NotesProvider {
private notesObservableServer = new Observable<Note>(true)
public notesObservable = this.notesObservableServer.getPublicApi()
public messageObservableServer = new Observable<{ message: string, type: MessageType }>(false)
public messageObservable = this.messageObservableServer.getPublicApi()
public sendErrorMessage(error: Error) {
this.messageObservableServer.send({ message: error.message, type: MessageType.ERROR })
}
private database = new IDB("notes", ["notes", "oplog"]);
private noteDB = this.database.getStore<DBNote>("notes");
private oplogDB = this.database.getStore<{ id: string, logs: OpLog[] }>("oplog");
private vaultKeys = new Map<string, Uint8Array>();
public apiLock = new Lock();
public apiLockRls = this.apiLock.getLock()
private syncLock = new Lock();
private _secureFile: SecureFile;
private generalEncryption: Uint8Array = undefined;
loginRequired() {
return !localStorage.getItem("refreshtoken") || !this.generalEncryption;
}
login() {
window.location.href = `${config.auth_server}/auth?client_id=${config.client_id}&scope=${config.permission}&redirect_uri=${encodeURIComponent(config.callback_url)}&response_type=code`
}
async getToken(code: string) {
let req = await fetch(`${config.auth_server}/api/oauth/refresh?grant_type=authorization_code&client_id=${config.client_id}&code=${code}`);
let res = await req.json();
if (!res.error) {
localStorage.setItem("refreshtoken", res.token);
let kb = this.passwordToKey(res.profile.enc_key);
localStorage.setItem("enc_key", b64.encode(kb));
this.generalEncryption = kb
} else {
return "Invalid Code"
}
}
constructor(public readonly baseurl = "") {
this._secureFile = new SecureFile(config.secure_file_server);
this._secureFile.jwtObservable.subscribe(async ([callback]) => {
let jwt = await this.getJWT();
callback(jwt);
})
let key = localStorage.getItem("enc_key");
if (key) {
this.generalEncryption = b64.decode(key)
}
}
public start() {
return this.apiLockRls.then(lock => lock.release()).then(() => { this.sync() })
}
private async getJWT() {
let lock = await this.apiLock.getLock()
try {
console.log("Getting JWT");
let req = await fetch(config.auth_server + "/api/oauth/jwt?refreshtoken=" + localStorage.getItem("refreshtoken"));
if (req.status !== 200) {
this.messageObservableServer.send({ message: "offline", type: MessageType.INFO });
throw new Error("Need login!")
}
let res = await req.json();
if (res.error) {
console.log("Refresh token invalid, forward to login")
localStorage.removeItem("refreshtoken");
this.login()
throw new Error("Need login!")
} else {
return res.token
}
} finally {
lock.release()
}
}
async Logout() {
localStorage.removeItem("refreshtoken");
window.location.reload();
}
private async sync() {
const lock = await this.syncLock.getLock()
const log = (...message: any[]) => {
console.log("[SYNC]: ", ...message)
}
log("Start")
try {
log("Fetching");
let [remotes, locals, oplogs] = await Promise.all([this._secureFile.list(), this.noteDB.getAll(), this.oplogDB.getAll()]);
log("Fetched");
// Create sync pairs (remote & local)
log("Building pairs");
let pairs: { local: DBNote, remote: IFile, id: string, oplog: OpLog[] }[] = [];
remotes.map(r => {
let lIdx = locals.findIndex(e => e._id === r._id);
let l: DBNote = undefined;
if (lIdx >= 0) {
l = locals[lIdx];
locals.splice(lIdx, 1);
}
let oIdx = oplogs.findIndex(e => e.id === r._id);
let oplog: OpLog[];
if (oIdx >= 0) {
oplog = oplogs[oIdx].logs;
oplogs.splice(oIdx, 1);
}
pairs.push({
remote: r,
local: l,
oplog,
id: r._id
})
})
locals.forEach(l => {
let oIdx = oplogs.findIndex(e => e.id === l._id);
let oplog: OpLog[] = undefined;
if (oIdx >= 0) {
oplog = oplogs[oIdx].logs;
if (oplog.length <= 0) oplog = undefined;
oplogs.splice(oIdx, 1);
}
pairs.push({
remote: undefined,
local: l,
oplog,
id: l._id
})
})
oplogs.forEach(oplog => {
if (!oplog) return;
if (oplog.logs.length > 0)
pairs.push({
remote: undefined,
local: undefined,
oplog: oplog.logs,
id: oplog.id
})
})
log("Pairs builded");
log("Start inspection");
for (let { local, remote, oplog, id } of pairs) {
const apply = async (old = false) => {
log("Apply OPLOG to", id);
for (let op of oplog) {
switch (op.type) {
case OpLogType.CHANGE:
log(id, "REMOTE CHANGE");
await this._secureFile.update(id, op.values.value, b64.encode(op.values.preview), op.date, old);
break;
case OpLogType.DELETE:
log(id, "REMOTE DELETE");
if (old) break; // if the deletion is old, just ignore
await this._secureFile.delete(id)
break;
case OpLogType.CREATE:
log(id, "REMOTE CREATE");
await this._secureFile.create(
"",
op.values.value,
"binary",
local.folder,
b64.encode(op.values.preview),
id,
op.date
);
break;
}
}
}
const create = async () => {
log(id, "LOCAL CREATAE/UPDATE");
let value = await this._secureFile.get(id);
let note: DBNote = {
_id: remote._id,
folder: remote.folder,
preview: b64.decode(remote.active.preview),
time: remote.active.time,
__value: new Uint8Array(value)
}
await this.noteDB.set(id, note);
}
try {
log(id, "LRO: ", !!local, !!remote, !!oplog)
if (remote && !oplog) {
if (local) {
let old = remote.active.time.valueOf() > local.time.valueOf();
if (old)
await create()
else
log(id, "DO NOTHING");
} else {
await create()
}
} else if (!remote && local && !oplog) { // No local changes, but remote deleted
log("LOCAL DELETE");
await this.noteDB.delete(id);
} else if (!remote && oplog) { // Remote does not exist, but oplog, just apply all changes including possible delete
await apply()
} else if (remote && oplog) {
let last = oplog[oplog.length - 1]
let old = remote.active.time.valueOf() > last.date.valueOf();
// if (local) { //If local changes and remote exist
if (old)
await create() // Will recreate local entry
await apply(old) // Will apply changes to remote
// }
// if (!local) { //If local ist deleted but remote exists check what is newer
// if (old)
// await create()
// await apply(old)
// }
} else {
log(id, "DO NOTHING");
}
} catch (err) {
console.error(err);
this.messageObservableServer.send({ message: "Error syncing: " + id, type: MessageType.ERROR })
}
await this.oplogDB.delete(id);
}
} finally {
log("Finished")
lock.release()
}
}
public getVaultKey(vault_id: string) {
let key = this.vaultKeys.get(vault_id);
if (!key) {
let lsk = localStorage.getItem("vault_" + vault_id);
if (lsk) {
key = b64.decode(lsk);
this.vaultKeys.set(vault_id, key);
}
}
return key;
}
public saveVaultKey(vault_id: string, key: Uint8Array, permanent?: boolean) {
this.vaultKeys.set(vault_id, key);
if (permanent) {
localStorage.setItem("vault_" + vault_id, b64.encode(key));
}
}
public getVaults(): Promise<VaultList> {
return this.noteDB.getAll()
.then(notes => notes
.filter(e => Decoder.decode(e.preview) === "__VAULT__")
.map(e => {
let value = e.__value;
let encrypted = false;
if (Decoder.decode(value) !== "__BASELINE__") encrypted = true;
return { name: e.folder, encrypted, id: e._id }
}));
}
public async createVault(name: string, key?: Uint8Array) {
let vault: DBNote = {
__value: this.encrypt("__BASELINE__", key),
_id: uuidv4(),
folder: name,
preview: Encoder.encode("__VAULT__"),
time: new Date()
}
let tx = this.database.transaction();
await Promise.all([
this.addop(vault._id, OpLogType.CREATE, {
value: vault.__value,
preview: vault.preview
}, tx),
this.noteDB.set(vault._id, vault)
]);
}
public async getVault(vault_id: string, key?: Uint8Array): Promise<IVault> {
let vault = await this.noteDB.get(vault_id);
if (!vault) throw new Error("Vault not found!");
if (this.decrypt(vault.__value, key) !== "__BASELINE__") throw new Error("Invalid password!");
return new NotesProvider.Vault(vault, key)
}
public passwordToKey(password: string) {
return new Uint8Array(sha256.arrayBuffer(password + config.client_id))
}
private _encrypt(value: Uint8Array, key?: Uint8Array): Uint8Array {
if (!key) return value;
var aesCtr = new aesjs.ModeOfOperation.ctr(key);
var encryptedBytes = aesCtr.encrypt(value);
return new Uint8Array(encryptedBytes);
}
private encrypt(value: string, key?: Uint8Array): Uint8Array {
let msg = this._encrypt(Encoder.encode(value), key)
return new Uint8Array(this._encrypt(msg, this.generalEncryption))
}
private _decrypt(value: ArrayBuffer, key?: Uint8Array): Uint8Array {
if (!key) value;
var aesCtr = new aesjs.ModeOfOperation.ctr(key);
var decryptedBytes = aesCtr.decrypt(value);
return new Uint8Array(decryptedBytes)
}
private decrypt(value: ArrayBuffer, key: Uint8Array): string {
let msg = this._decrypt(value, key)
return Decoder.decode(this._decrypt(msg, this.generalEncryption))
}
async addop(note_id: string, type: OpLogType, values: { value: Uint8Array, preview: Uint8Array }, transaction?: Transaction) {
let tx = transaction || Notes.oplogDB.transaction();
let oplog = await Notes.oplogDB.get(note_id, tx);
if (!oplog) oplog = { logs: [], id: note_id };
oplog.logs.push({
date: new Date(),
type,
values
})
await Notes.oplogDB.set(note_id, oplog, tx);
}
static Vault = class implements IVault {
id: string;
name: string;
encrypted: boolean = false;
constructor(private vault: Note, private key?: Uint8Array) {
if (key) this.encrypted = true;
this.id = vault._id;
this.name = vault.folder;
}
private encrypt(data: string) {
return Notes.encrypt(data, this.key);
}
private decrypt(data: ArrayBuffer) {
return Notes.decrypt(data, this.key);
}
async getAllNotes() {
return Notes.noteDB.getAll()
.then(all => all
.filter(e => e.folder === this.vault._id)
.map<BaseNote>(e => {
let new_note = clonedeep(<Note>e) as BaseNote
delete (<any>new_note).__value
new_note.preview = this.decrypt(e.preview)
return new_note;
}));
}
async searchNote(term: string) {
let all = await this.getAllNotes();
return all.filter(e => e.preview.indexOf(term) >= 0)
}
newNote(): ViewNote {
return {
_id: uuidv4(),
folder: this.vault._id,
time: new Date(),
__value: "",
preview: ""
}
}
async saveNote(note: ViewNote) {
let lock = await Notes.syncLock.getLock();
const tx = Notes.database.transaction(Notes.noteDB, Notes.oplogDB);
let old_note = await Notes.noteDB.get(note._id, tx);
let new_note = clonedeep(<Note>note) as DBNote;
new_note.__value = this.encrypt(note.__value)
let [title, preview] = note.__value.split("\n");
if (preview) preview = "\n" + preview;
else preview = ""
new_note.preview = this.encrypt(title + preview)
await Promise.all([
Notes.addop(note._id, !old_note ? OpLogType.CREATE : OpLogType.CHANGE, {
value: new_note.__value,
preview: new_note.preview
}, tx),
Notes.noteDB.set(note._id, new_note, tx)
])
lock.release();
}
async getNote(id: string): Promise<ViewNote> {
let note = await Notes.noteDB.get(id);
if (!note) return undefined;
let new_note = clonedeep(<Note>note) as ViewNote;
new_note.__value = this.decrypt(note.__value);
return new_note;
}
async deleteNote(id: string) {
let lock = await Notes.syncLock.getLock();
let tx = Notes.database.transaction(Notes.oplogDB, Notes.noteDB)
await Promise.all([
Notes.addop(id, OpLogType.DELETE, null, tx),
Notes.noteDB.delete(id, tx)
])
lock.release();
}
}
}
const Notes = new NotesProvider()
export default Notes;
(<any>window).api = Notes

6
src/page.ts Executable file
View File

@ -0,0 +1,6 @@
import { Component } from "preact";
export interface PageProps {
state: any;
}
export abstract class Page<P extends PageProps, S> extends Component<P, S> { }

1
src/test.worker.ts Executable file
View File

@ -0,0 +1 @@
console.log("Hello from worker!")

2
src/types.d.ts vendored Executable file
View File

@ -0,0 +1,2 @@
// import { Component } from "preact";
declare module "*.svg";

1
src/vars.scss Executable file
View File

@ -0,0 +1 @@
$header_icon_width: calc(3.1875rem + var(--universal-padding) / 2);

23
tsconfig.json Executable file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"outDir": "./public",
"target": "es6",
"module": "commonjs",
"noImplicitAny": false,
"removeComments": true,
"sourceMap": true,
"jsx": "react",
"jsxFactory": "h",
"lib": [
"es2015",
"dom",
"esnext"
],
"resolveJsonModule": true
},
"include": [
"./src/types.d.ts",
"./src/**/*.tsx",
"./src/**/*.ts"
]
}

BIN
webpack-stats.json Executable file

Binary file not shown.

86
webpack.config.js Executable file
View File

@ -0,0 +1,86 @@
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const WorkboxPlugin = require("workbox-webpack-plugin");
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const Visualizer = require('webpack-visualizer-plugin');
const path = require("path");
module.exports = {
devServer: {
host: "0.0.0.0", // Defaults to `localhost`
open: false, // Open the page in browser,
contentBase: path.join(__dirname, 'dist'),
disableHostCheck: true
},
plugins: [
new CopyWebpackPlugin([{ from: "public", to: "public" }]),
new HtmlWebpackPlugin({
title: 'SecureNotes',
template: "./src/index.html"
}),
new MiniCssExtractPlugin({
filename: "[name].css",
chunkFilename: "[id].css"
}),
new BundleAnalyzerPlugin({
analyzerMode: "static",
openAnalyzer: false
}),
new Visualizer(),
// new WorkboxPlugin.GenerateSW({
// swDest: "sw.js",
// importWorkboxFrom: 'local',
// clientsClaim: true,
// // importsDirectory: "/dist",
// directoryIndex: 'index.html',
// ignoreUrlParametersMatching: [/./],
// runtimeCaching: [{
// urlPattern: /images/,
// handler: 'cacheFirst',
// options: {
// cacheName: 'images',
// expiration: {
// maxEntries: 1000,
// //Recheck after 30 Days
// maxAgeSeconds: 30 * 24 * 60 * 60
// },
// },
// }, {
// urlPattern: /api/,
// handler: 'networkOnly'
// }]
// })
],
entry: "./src/index.tsx",
// Enable sourcemaps for debugging webpack's output.
devtool: "eval",
resolve: {
// Add '.ts' and '.tsx' as resolvable extensions.
extensions: [".webpack.js", ".web.js", ".ts", ".tsx", ".js"]
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
}, {
test: /\.(png|jpg)$/,
loader: "file-loader",
}, {
test: /\.scss$/,
use: [
"style-loader", // creates style nodes from JS strings
MiniCssExtractPlugin.loader,
"css-loader", // translates CSS into CommonJS
"sass-loader" // compiles Sass to CSS, using Node Sass by default
]
},
{
test: /\.svg$/,
use: ['preact-svg-loader'],
}
]
}
};