import { h, Component, createRef } from 'preact' import htm from 'htm' import { signal } from 'preact/signals' import { Keys, Key } from 'key' import Crypto from 'crypto' const html = htm.bind(h) export class NodeUI extends Component { constructor(props) {//{{{ super(props) this.menu = signal(false) this.node = signal(null) this.nodeContent = createRef() this.keys = signal([]) this.page = signal('node') window.addEventListener('popstate', evt=>{ if(evt.state && evt.state.hasOwnProperty('nodeID')) this.goToNode(evt.state.nodeID, true) else this.goToNode(0, true) }) window.addEventListener('keydown', evt=>this.keyHandler(evt)) }//}}} render() {//{{{ if(this.node.value === null) return let node = this.node.value let crumbs = [ html`
this.goToNode(0)}>Start
` ] crumbs = crumbs.concat(node.Crumbs.slice(0).map(node=> html`
this.goToNode(node.ID)}>${node.Name}
` ).reverse()) let children = node.Children.sort((a,b)=>{ if(a.Name.toLowerCase() > b.Name.toLowerCase()) return 1 if(a.Name.toLowerCase() < b.Name.toLowerCase()) return -1 return 0 }).map(child=>html`
this.goToNode(child.ID)}>${child.Name}
`) let modified = '' if(this.props.app.nodeModified.value) modified = 'modified' // Page to display let page = '' switch(this.page.value) { case 'node': if(node.ID == 0) { page = html` ${children.length > 0 ? html`
${children}
` : html``} ` } else { let padlock = '' if(node.CryptoKeyID > 0) padlock = html`` page = html` ${children.length > 0 ? html`
${children}
` : html``}
${node.Name} ${padlock}
<${NodeContent} key=${node.ID} node=${node} ref=${this.nodeContent} /> <${NodeFiles} node=${this.node.value} /> ` } break case 'upload': page = html`<${UploadUI} nodeui=${this} />` break case 'node-properties': page = html`<${NodeProperties} nodeui=${this} />` break case 'keys': page = html`<${Keys} nodeui=${this} />` break } let menu = '' if(this.menu.value) menu = html`<${Menu} nodeui=${this} />` return html` ${menu}
this.saveNode()}>
document.getElementById('app').classList.toggle('toggle-tree')} />
Notes
this.createNode(evt)}>
{ evt.stopPropagation(); this.showPage('keys')}}>
${crumbs}
${page} ` }//}}} async componentDidMount() {//{{{ // When rendered and fetching the node, keys could be needed in order to // decrypt the content. await this.retrieveKeys() this.props.app.startNode.retrieve(node=>{ this.node.value = node // The tree isn't guaranteed to have loaded yet. This is also run from // the tree code, in case the node hasn't loaded. this.props.app.tree.crumbsUpdateNodes(node) }) }//}}} keyHandler(evt) {//{{{ let handled = true switch(evt.key.toUpperCase()) { case 'E': if(evt.shiftKey && evt.altKey) this.showPage('keys') else handled = false break case 'N': if(evt.shiftKey && evt.altKey) this.createNode() else handled = false break case 'P': if(evt.shiftKey && evt.altKey) this.showPage('node-properties') else handled = false break case 'S': if(evt.ctrlKey || (evt.shiftKey && evt.altKey)) this.saveNode() else handled = false break case 'U': if(evt.shiftKey && evt.altKey) this.showPage('upload') else handled = false default: handled = false } if(handled) { evt.preventDefault() evt.stopPropagation() } }//}}} showMenu(evt) {//{{{ evt.stopPropagation() this.menu.value = true }//}}} logout() {//{{{ window.localStorage.removeItem('session.UUID') location.href = '/' }//}}} goToNode(nodeID, dontPush) {//{{{ if(this.props.app.nodeModified.value) { if(!confirm("Changes not saved. Do you want to discard changes?")) return } if(!dontPush) history.pushState({ nodeID }, '', `/?node=${nodeID}`) // New node is fetched in order to retrieve content and files. // Such data is unnecessary to transfer for tree/navigational purposes. let node = new Node(this.props.app, nodeID) node.retrieve(node=>{ this.props.app.nodeModified.value = false this.node.value = node this.showPage('node') // Tree needs to know another node is selected, in order to render any // previously selected node not selected. this.props.app.tree.setSelected(node) // Hide tree toggle, as this would be the next natural action to do manually anyway. // At least in mobile mode. document.getElementById('app').classList.remove('toggle-tree') }) }//}}} createNode(evt) {//{{{ if(evt) evt.stopPropagation() let name = prompt("Name") if(!name) return this.node.value.create(name, nodeID=>this.goToNode(nodeID)) }//}}} saveNode() {//{{{ let content = this.nodeContent.current.contentDiv.current.value this.node.value.setContent(content) this.node.value.save(()=>this.props.app.nodeModified.value = false) }//}}} renameNode() {//{{{ let name = prompt("New name") if(!name) return this.node.value.rename(name, ()=>{ this.goToNode(this.node.value.ID) this.menu.value = false }) }//}}} deleteNode() {//{{{ if(!confirm("Do you want to delete this note and all sub-notes?")) return this.node.value.delete(()=>{ this.goToNode(this.node.value.ParentID) this.menu.value = false }) }//}}} async retrieveKeys() {//{{{ return new Promise((resolve, reject)=>{ this.props.app.request('/key/retrieve', {}) .then(res=>{ this.keys.value = res.Keys.map(keyData=>new Key(keyData, this.keyCounter)) resolve(this.keys.value) }) .catch(reject) }) }//}}} keyCounter() {//{{{ return window._app.current.request('/key/counter', {}) .then(res=>BigInt(res.Counter)) .catch(window._app.current.responseError) }//}}} getKey(id) {//{{{ let keys = this.keys.value for(let i = 0; i < keys.length; i++) if(keys[i].ID == id) return keys[i] return null }//}}} showPage(pg) {//{{{ this.page.value = pg }//}}} } class NodeContent extends Component { constructor(props) {//{{{ super(props) this.contentDiv = createRef() this.state = { modified: false, } }//}}} render({ node }) {//{{{ let content = '' try { content = node.content() } catch(err) { return html`
${err.message}
` } return html`
` }//}}} componentDidMount() {//{{{ this.resize() window.addEventListener('resize', ()=>this.resize()) }//}}} componentDidUpdate() {//{{{ this.resize() }//}}} contentChanged() {//{{{ window._app.current.nodeModified.value = true this.resize() }//}}} resize() {//{{{ let textarea = document.getElementById('node-content') if(textarea) textarea.parentNode.dataset.replicatedValue = textarea.value }//}}} unlock() {//{{{ let pass = prompt(`Password for "${this.props.model.description}"`) if(!pass) return try { this.props.model.unlock(pass) this.forceUpdate() } catch(err) { alert(err) } }//}}} } class NodeFiles extends Component { render({ node }) {//{{{ if(node.Files === null || node.Files.length == 0) return let files = node.Files .sort((a, b)=>{ if(a.Filename.toUpperCase() < b.Filename.toUpperCase()) return -1 if(a.Filename.toUpperCase() > b.Filename.toUpperCase()) return 1 return 0 }) .map(file=> html`
node.download(file.ID)}>${file.Filename}
${this.formatSize(file.Size)}
` ) return html`
Files
${files}
` }//}}} formatSize(size) {//{{{ if(size < 1048576) { return `${Math.round(size / 1024)} KiB` } else { return `${Math.round(size / 1048576)} MiB` } }//}}} } export class Node { constructor(app, nodeID) {//{{{ this.app = app this.ID = nodeID this.ParentID = 0 this.UserID = 0 this.CryptoKeyID = 0 this.Name = '' this._content = '' this.Children = [] this.Crumbs = [] this.Files = [] this._decrypted = false this._expanded = false // start value for the TreeNode component, // it doesn't control it afterwards. // Used to expand the crumbs upon site loading. }//}}} retrieve(callback) {//{{{ this.app.request('/node/retrieve', { ID: this.ID }) .then(res=>{ this.ParentID = res.Node.ParentID this.UserID = res.Node.UserID this.CryptoKeyID = res.Node.CryptoKeyID this.Name = res.Node.Name this._content = res.Node.Content this.Children = res.Node.Children this.Crumbs = res.Node.Crumbs this.Files = res.Node.Files callback(this) }) .catch(this.app.responseError) }//}}} delete(callback) {//{{{ this.app.request('/node/delete', { NodeID: this.ID, }) .then(callback) .catch(this.app.responseError) }//}}} create(name, callback) {//{{{ this.app.request('/node/create', { Name: name.trim(), ParentID: this.ID, }) .then(res=>{ callback(res.Node.ID) }) .catch(this.app.responseError) }//}}} async save(callback) {//{{{ try { await this.#encrypt() let req = { NodeID: this.ID, Content: this._content, CryptoKeyID: this.CryptoKeyID, } this.app.request('/node/update', req) .then(callback) .catch(this.app.responseError) } catch (err) { this.app.responseError(err) } }//}}} rename(name, callback) {//{{{ this.app.request('/node/rename', { Name: name.trim(), NodeID: this.ID, }) .then(callback) .catch(this.app.responseError) }//}}} download(fileID) {//{{{ let headers = { 'Content-Type': 'application/json', } if(this.app.session.UUID !== '') headers['X-Session-Id'] = this.app.session.UUID let fname = "" fetch("/node/download", { method: 'POST', headers, body: JSON.stringify({ NodeID: this.ID, FileID: fileID, }), }) .then(response=>{ let match = response.headers.get('content-disposition').match(/filename="([^"]*)"/) fname = match[1] return response.blob() }) .then(blob=>{ let url = window.URL.createObjectURL(blob) let a = document.createElement('a') a.href = url a.download = fname document.body.appendChild(a) // we need to append the element to the dom -> otherwise it will not work in firefox a.click() a.remove() //afterwards we remove the element again }) }//}}} content() {//{{{ if(this.CryptoKeyID != 0 && !this._decrypted) this.#decrypt() return this._content }//}}} setContent(new_content) {//{{{ this._content = new_content if(this.CryptoKeyID == 0) // Logic behind plaintext not being decrypted is that // only encrypted values can be in a decrypted state. this._decrypted = false else this._decrypted = true }//}}} async setCryptoKey(new_key) {//{{{ return this.#encrypt(true, new_key) }//}}} #decrypt() {//{{{ if(this.CryptoKeyID == 0 || this._decrypted) return let obj_key = this.app.nodeUI.current.getKey(this.CryptoKeyID) if(obj_key === null || obj_key.ID != this.CryptoKeyID) throw('Invalid key') // Ask user to unlock key first var pass = null while(pass || obj_key.status() == 'locked') { pass = prompt(`Password for "${obj_key.description}"`) if(!pass) throw new Error(`Key "${obj_key.description}" is locked`) try { obj_key.unlock(pass) } catch(err) { alert(err) } pass = null } if(obj_key.status() == 'locked') throw new Error(`Key "${obj_key.description}" is locked`) let crypto = new Crypto(obj_key.key) this._decrypted = true this._content = sjcl.codec.utf8String.fromBits( crypto.decrypt(this._content) ) }//}}} async #encrypt(change_key = false, new_key = null) {//{{{ // Nothing to do if not changing key and already encrypted. if(!change_key && this.CryptoKeyID != 0 && !this._decrypted) return this._content let content = this.content() // Changing key to no encryption or already at no encryption - // set to not decrypted (only encrypted values can be // decrypted) and return plain value. if((change_key && new_key === null) || (!change_key && this.CryptoKeyID == 0)) { this._decrypted = false this.CryptoKeyID = 0 return content } let key_id = change_key ? new_key.ID : this.CryptoKeyID let obj_key = this.app.nodeUI.current.getKey(key_id) if(obj_key === null || obj_key.ID != key_id) throw('Invalid key') if(obj_key.status() == 'locked') throw new Error(`Key "${obj_key.description}" is locked`) let crypto = new Crypto(obj_key.key) let content_bits = sjcl.codec.utf8String.toBits(content) let counter = await this.app.nodeUI.current.keyCounter() this.CryptoKeyID = obj_key.ID this._content = crypto.encrypt(content_bits, counter, true) this._decrypted = false return this._content }//}}} } class Menu extends Component { render({ nodeui }) {//{{{ return html`
nodeui.menu.value = false}>
` }//}}} } class UploadUI extends Component { constructor(props) {//{{{ super(props) this.file = createRef() this.filelist = signal([]) this.fileRefs = [] this.progressRefs = [] }//}}} render({ nodeui }) {//{{{ let filelist = this.filelist.value let files = [] for(let i = 0; i < filelist.length; i++) { files.push(html`
${filelist.item(i).name}
`) } return html`
nodeui.upload.value = false}>
this.upload()} multiple />
${files}
` }//}}} componentDidMount() {//{{{ this.file.current.focus() }//}}} upload() {//{{{ let nodeID = this.props.nodeui.node.value.ID this.fileRefs = [] this.progressRefs = [] let input = this.file.current this.filelist.value = input.files for(let i = 0; i < input.files.length; i++) { this.fileRefs.push(createRef()) this.progressRefs.push(createRef()) this.postFile( input.files[i], nodeID, progress=>{ this.progressRefs[i].current.innerHTML = `${progress}%` }, res=>{ this.props.nodeui.node.value.Files.push(res.File) this.props.nodeui.forceUpdate() this.fileRefs[i].current.classList.add("done") this.progressRefs[i].current.classList.add("done") this.props.nodeui.upload.value = false }) } }//}}} postFile(file, nodeID, progressCallback, doneCallback) {//{{{ var formdata = new FormData() formdata.append('file', file) formdata.append('NodeID', nodeID) var request = new XMLHttpRequest() request.addEventListener("error", ()=>{ window._app.current.responseError({ upload: "An unknown error occured" }) }) request.addEventListener("loadend", ()=>{ if(request.status != 200) { window._app.current.responseError({ upload: request.statusText }) return } let response = JSON.parse(request.response) if(!response.OK) { window._app.current.responseError({ upload: response.Error }) return } doneCallback(response) }) request.upload.addEventListener('progress', evt=>{ var fileSize = file.size if(evt.loaded <= fileSize) progressCallback(Math.round(evt.loaded / fileSize * 100)) if(evt.loaded == evt.total) progressCallback(100) }) request.open('post', '/node/upload') request.setRequestHeader("X-Session-Id", window._app.current.session.UUID) //request.timeout = 45000 request.send(formdata) }//}}} } class NodeProperties extends Component { constructor(props) {//{{{ super(props) this.props.nodeui.retrieveKeys() this.selected_key_id = 0 }//}}} render({ nodeui }) {//{{{ let save = true let keys = nodeui.keys.value .sort((a, b)=>{ if(a.description < b.description) return -1 if(a.description > b.description) return 1 return 0 }) .map(key=>{ this.props.nodeui.keys.value.some(uikey=>{ if(uikey.ID == nodeui.node.value.ID) { save = (uikey.status() == 'unlocked') return true } }) return html`
this.selected_key_id = key.ID} />
` }) return html`

Note properties

These properties are only for this note.

Encryption

this.selected_key_id = 0} />
${keys} ${save ? html`` : ''}
` }//}}} async save() {//{{{ let nodeui = this.props.nodeui let node = nodeui.node.value // Find the actual key object used for encryption let new_key = nodeui.getKey(this.selected_key_id) let current_key = nodeui.getKey(node.CryptoKeyID) if(current_key && current_key.status() == 'locked') { alert("Decryption key is locked and can not be used.") return } if(new_key && new_key.status() == 'locked') { alert("Key is locked and can not be used.") return } await node.setCryptoKey(new_key) node.save(()=>this.props.nodeui.showPage('node')) }//}}} } // vim: foldmethod=marker