Keys unlocking/locking

This commit is contained in:
Magnus Åhall 2023-07-01 20:33:26 +02:00
parent f9dfc8835c
commit c65f46a17d
13 changed files with 3271 additions and 82 deletions

34
key.go Normal file
View File

@ -0,0 +1,34 @@
package main
import (
// External
"github.com/jmoiron/sqlx"
// Standard
)
type Key struct {
ID int
UserID int `db:"user_id"`
Description string
Key string
}
func (session Session) Keys() (keys []Key, err error) {
var rows *sqlx.Rows
if rows, err = db.Queryx(`SELECT * FROM crypto_key WHERE user_id=$1`, session.UserID); err != nil {
return
}
defer rows.Close()
keys = []Key{}
for rows.Next() {
key := Key{}
if err = rows.StructScan(&key); err != nil {
return
}
keys = append(keys, key)
}
return
}

25
main.go
View File

@ -22,7 +22,7 @@ import (
const VERSION = "v0.2.2";
const LISTEN_HOST = "0.0.0.0";
const DB_SCHEMA = 6
const DB_SCHEMA = 7
var (
flagPort int
@ -76,6 +76,7 @@ func main() {// {{{
http.HandleFunc("/node/delete", nodeDelete)
http.HandleFunc("/node/upload", nodeUpload)
http.HandleFunc("/node/download", nodeDownload)
http.HandleFunc("/key/retrieve", keyRetrieve)
http.HandleFunc("/ws", websocketHandler)
http.HandleFunc("/", staticHandler)
@ -540,6 +541,28 @@ func nodeFiles(w http.ResponseWriter, r *http.Request) {// {{{
})
}// }}}
func keyRetrieve(w http.ResponseWriter, r *http.Request) {// {{{
log.Println("/key/retrieve")
var err error
var session Session
if session, _, err = ValidateSession(r, true); err != nil {
responseError(w, err)
return
}
keys, err := session.Keys()
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
"Keys": keys,
})
}// }}}
func newTemplate(requestPath string) (tmpl *template.Template, err error) {// {{{
// Append index.html if needed for further reading of the file
p := requestPath

10
sql/0007.sql Normal file
View File

@ -0,0 +1,10 @@
CREATE TABLE public.crypto_key (
id serial NOT NULL,
user_id int4 NOT NULL,
description varchar(255) NOT NULL DEFAULT '',
"key" char(144) NOT NULL,
CONSTRAINT crypto_key_pk PRIMARY KEY (id),
CONSTRAINT crypto_key_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
);
COMMENT ON COLUMN public.crypto_key.key IS 'salt(16 bytes) + [key encrypted with pbkdf2(pass, salt)]';

View File

@ -19,16 +19,8 @@ body {
height: 100%;
}
h1 {
color: #abc837;
}
.layout-crumbs {
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank";
grid-template-columns: 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
/* blank */
}
.layout-crumbs #tree {
display: none;
margin-top: 0px;
font-size: 1em;
}
#blackout {
position: absolute;
@ -113,10 +105,21 @@ h1 {
#upload .files .progress.done {
color: #0a0;
}
#properties {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #333;
background: #fff;
border: 2px solid #000;
padding: 16px;
z-index: 1025;
}
header {
display: grid;
grid-area: header;
grid-template-columns: min-content 1fr min-content min-content;
grid-template-columns: min-content 1fr repeat(3, min-content);
align-items: center;
padding: 0px;
color: #333c11;
@ -149,6 +152,13 @@ header .add {
padding-right: 16px;
cursor: pointer;
}
header .keys {
padding-right: 16px;
}
header .keys img {
cursor: pointer;
height: 24px;
}
header .menu {
font-size: 1.25em;
padding-right: 16px;
@ -341,6 +351,34 @@ header .menu {
white-space: nowrap;
text-align: right;
}
#keys {
padding: 32px;
color: #333;
}
#keys .key-list {
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 12px 12px;
align-items: end;
margin-top: 16px;
font-size: 0.85em;
}
#keys .key-list .status {
cursor: pointer;
}
#keys .key-list .status img {
height: 32px;
}
#keys .key-list .status .locked {
color: #a00;
}
#keys .key-list .status .unlocked {
color: #0a0;
}
#keys .key-list .description {
cursor: pointer;
padding-bottom: 4px;
}
.layout-tree {
display: grid;
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree files" "tree blank";
@ -386,6 +424,36 @@ header .menu {
.layout-crumbs #tree {
display: none;
}
.layout-keys {
display: grid;
grid-template-areas: "header" "keys";
grid-template-columns: 1fr;
grid-template-rows: min-content /* header */ 1fr;
/* blank */
color: #fff;
height: 100%;
}
.layout-keys #crumbs {
display: none;
}
.layout-keys .child-nodes {
display: none;
}
.layout-keys .node-name {
display: none;
}
.layout-keys .grow-wrap {
display: none;
}
.layout-keys #file-section {
display: none;
}
.layout-keys #tree {
display: none;
}
.layout-keys #keys {
display: block;
}
#app {
display: grid;
grid-template-areas: "header header" "tree crumbs" "tree child-nodes" "tree name" "tree content" "tree files" "tree blank";
@ -396,7 +464,6 @@ header .menu {
height: 100%;
}
#app.toggle-tree {
/* blank */
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank";
grid-template-columns: 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
@ -405,12 +472,8 @@ header .menu {
#app.toggle-tree #tree {
display: none;
}
#app.toggle-tree #tree {
display: none;
}
@media only screen and (max-width: 932px) {
#app {
/* blank */
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank";
grid-template-columns: 1fr;
grid-template-rows: min-content /* header */ min-content /* crumbs */ min-content /* child-nodes */ min-content /* name */ min-content /* content */ min-content /* files */ 1fr;
@ -419,9 +482,6 @@ header .menu {
#app #tree {
display: none;
}
#app #tree {
display: none;
}
#app.toggle-tree {
display: grid;
grid-template-areas: "header" "tree";

View File

@ -0,0 +1,76 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="824.2666"
height="800"
viewBox="0 0 218.0872 211.66667"
version="1.1"
id="svg8"
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
sodipodi:docname="padlock-closed.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2">
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.5"
inkscape:cx="679"
inkscape:cy="-6"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="2556"
inkscape:window-height="1404"
inkscape:window-x="2560"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d6d6d6"
showborder="true"
showguides="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-268.56613,-53.644456)">
<g
id="XMLID_509_"
transform="matrix(0.64141414,0,0,0.64141414,307.05737,53.644456)"
style="fill:#a02c2c">
<path
id="XMLID_510_"
d="m 65,330 h 200 c 8.284,0 15,-6.716 15,-15 V 145 c 0,-8.284 -6.716,-15 -15,-15 H 250 V 85 C 250,38.131 211.869,0 165,0 118.131,0 80,38.131 80,85 v 45 H 65 c -8.284,0 -15,6.716 -15,15 v 170 c 0,8.284 6.716,15 15,15 z M 180,234.986 V 255 c 0,8.284 -6.716,15 -15,15 -8.284,0 -15,-6.716 -15,-15 v -20.014 c -6.068,-4.565 -10,-11.824 -10,-19.986 0,-13.785 11.215,-25 25,-25 13.785,0 25,11.215 25,25 0,8.162 -3.932,15.421 -10,19.986 z M 110,85 c 0,-30.327 24.673,-55 55,-55 30.327,0 55,24.673 55,55 v 45 H 110 Z"
style="fill:#a02c2c" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="824.2666"
height="800"
viewBox="0 0 218.0872 211.66667"
version="1.1"
id="svg8"
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
sodipodi:docname="padlock-open.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2">
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.5"
inkscape:cx="679"
inkscape:cy="-6"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="2556"
inkscape:window-height="1404"
inkscape:window-x="2560"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d6d6d6"
showborder="true"
showguides="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-268.56613,-53.644456)">
<g
id="g8389">
<path
id="path8097"
d="m 348.74929,265.31112 h 128.28283 c 5.31347,0 9.62121,-4.30774 9.62121,-9.62121 v -109.0404 c 0,-5.31348 -4.30774,-9.62122 -9.62121,-9.62122 h -9.62122 -109.0404 -9.62121 c -5.31348,0 -9.62121,4.30774 -9.62121,9.62122 v 109.0404 c 0,5.31347 4.30773,9.62121 9.62121,9.62121 z m 73.76263,-60.94332 v 12.83726 c 0,5.31348 -4.30774,9.62121 -9.62122,9.62121 -5.31347,0 -9.62121,-4.30773 -9.62121,-9.62121 V 204.3678 c -3.8921,-2.92806 -6.41414,-7.58408 -6.41414,-12.8193 0,-8.8419 7.19346,-16.03536 16.03535,-16.03536 8.8419,0 16.03536,7.19346 16.03536,16.03536 0,5.23522 -2.52204,9.89124 -6.41414,12.8193 z"
style="fill:#5aa02c;stroke-width:0.641414"
sodipodi:nodetypes="sssssccsssscssscsssc" />
<path
id="path8197"
d="m 377.60654,137.21538 v -28.86363 c 0,-30.062442 -24.45777,-54.520205 -54.52021,-54.520205 -30.06243,0 -54.5202,24.457763 -54.5202,54.520205 v 28.86363 m 19.24243,0 v -28.86363 c 0,-19.45217 15.82561,-35.277781 35.27777,-35.277781 19.45217,0 35.27778,15.825611 35.27778,35.277781 v 28.86363"
style="fill:#5aa02c;stroke-width:0.641414"
sodipodi:nodetypes="csssccsssc"
inkscape:transform-center-x="-44.498125" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

46
static/images/padlock.svg Normal file
View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg
fill="#000000"
height="800"
width="557.57574"
version="1.1"
id="Layer_1"
viewBox="0 0 229.99999 330"
xml:space="preserve"
sodipodi:docname="lock-svgrepo-com.svg"
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs6955" /><sodipodi:namedview
id="namedview6953"
pagecolor="#efb591"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showgrid="false"
inkscape:zoom="1.34875"
inkscape:cx="157.924"
inkscape:cy="399.62929"
inkscape:window-width="2190"
inkscape:window-height="1404"
inkscape:window-x="1463"
inkscape:window-y="16"
inkscape:window-maximized="1"
inkscape:current-layer="Layer_1" />
<g
id="XMLID_509_"
transform="translate(-50)"
style="fill:#ffffff">
<path
id="XMLID_510_"
d="m 65,330 h 200 c 8.284,0 15,-6.716 15,-15 V 145 c 0,-8.284 -6.716,-15 -15,-15 H 250 V 85 C 250,38.131 211.869,0 165,0 118.131,0 80,38.131 80,85 v 45 H 65 c -8.284,0 -15,6.716 -15,15 v 170 c 0,8.284 6.716,15 15,15 z M 180,234.986 V 255 c 0,8.284 -6.716,15 -15,15 -8.284,0 -15,-6.716 -15,-15 v -20.014 c -6.068,-4.565 -10,-11.824 -10,-19.986 0,-13.785 11.215,-25 25,-25 13.785,0 25,11.215 25,25 0,8.162 -3.932,15.421 -10,19.986 z M 110,85 c 0,-30.327 24.673,-55 55,-55 30.327,0 55,24.673 55,55 v 45 H 110 Z"
style="fill:#ffffff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -5,6 +5,8 @@
<meta name="viewport" content="initial-scale=1.0, user-scalable=yes" />
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/main.css">
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/login.css">
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/css_reload.js"></script>
<script type="importmap">
{
"imports": {
@ -16,12 +18,12 @@
"preact/signals": "/js/{{ .VERSION }}/lib/signals/signals.mjs",
"htm": "/js/{{ .VERSION }}/lib/htm/htm.mjs",
"session": "/js/{{ .VERSION }}/session.mjs",
"node": "/js/{{ .VERSION }}/node.mjs"
"node": "/js/{{ .VERSION }}/node.mjs",
"key": "/js/{{ .VERSION }}/key.mjs",
"crypto": "/js/{{ .VERSION }}/crypto.mjs"
}
}
</script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/css_reload.js"></script>
</head>
<body>

69
static/js/crypto.mjs Normal file
View File

@ -0,0 +1,69 @@
export default class Crypto {
constructor(key) {
if(typeof key === 'string')
this.key = sjcl.codec.base64.toBits(base64_key)
else
this.key = key
this.aes = new sjcl.cipher.aes(this.key)
}
static generate_key() {
return sjcl.random.randomWords(8)
}
static pass_to_key(pass, salt = null) {
if(salt === null)
salt = sjcl.random.randomWords(4) // 128 bits (16 bytes)
let key = sjcl.misc.pbkdf2(pass, salt, 10000)
return {
salt,
key,
}
}
encrypt(plaintext_data_in_bits, counter, return_encoded = true) {
// 8 bytes of random data, (1 word = 4 bytes) * 2
// with 8 bytes of byte encoded counter is used as
// IV to guarantee a non-repeated IV (which is a catastrophe).
// Assumes counter value is kept unique. Counter is taken from
// Postgres sequence.
let random_bits = sjcl.random.randomWords(2)
let iv_bytes = sjcl.codec.bytes.fromBits(random_bits)
for (let i = 0; i < 8; ++i) {
let mask = 0xffn << BigInt(i*8)
let counter_i_byte = (counter & mask) >> BigInt(i*8)
iv_bytes[15-i] = Number(counter_i_byte)
}
let iv = sjcl.codec.bytes.toBits(iv_bytes)
let encrypted = sjcl.mode['ccm'].encrypt(
this.aes,
plaintext_data_in_bits,
iv,
)
// Returning 16 bytes (4 words) IV + encrypted data.
if(return_encoded)
return sjcl.codec.base64.fromBits(
iv.concat(encrypted)
)
else
return iv.concat(encrypted)
}
decrypt(encrypted_base64_data) {
try {
let encoded = sjcl.codec.base64.toBits(encrypted_base64_data)
let iv = encoded.slice(0, 4) // in words (4 bytes), not bytes
let encrypted_data = encoded.slice(4)
return sjcl.mode['ccm'].decrypt(this.aes, encrypted_data, iv)
} catch(err) {
if(err.message == `ccm: tag doesn't match`)
throw('Decryption failed')
else
throw(err)
}
}
}

113
static/js/key.mjs Normal file
View File

@ -0,0 +1,113 @@
import 'preact/devtools'
import { h, Component } from 'preact'
import htm from 'htm'
import Crypto from 'crypto'
const html = htm.bind(h)
export class Keys extends Component {
constructor(props) {//{{{
super(props)
this.retrieveKeys()
}//}}}
render({ nodeui }) {//{{{
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=>
html`<${KeyComponent} key=${`key-${key.ID}`} model=${key} />`
)
return html`
<div id="keys">
<h1>Keys</h1>
<div class="key-list">
${keys}
</div>
</div>`
}//}}}
retrieveKeys() {//{{{
window._app.current.request('/key/retrieve', {})
.then(res=>{
this.props.nodeui.keys.value = res.Keys.map(keyData=>new Key(keyData))
})
.catch(window._app.current.responseError)
}//}}}
}
export class Key {
constructor(data) {//{{{
this.ID = data.ID
this.description = data.Description
this.unlockedKey = data.Key
this.key = null
let hex_key = window.sessionStorage.getItem(`key-${this.ID}`)
if(hex_key)
this.key = sjcl.codec.hex.toBits(hex_key)
}//}}}
status() {//{{{
if(this.key === null)
return 'locked'
return 'unlocked'
}//}}}
lock() {//{{{
this.key = null
window.sessionStorage.removeItem(`key-${this.ID}`)
}//}}}
unlock(password) {//{{{
let db = sjcl.codec.hex.toBits(this.unlockedKey)
let salt = db.slice(0, 4)
let pass_key = Crypto.pass_to_key(password, salt)
let crypto = new Crypto(pass_key.key)
this.key = crypto.decrypt(sjcl.codec.base64.fromBits(db.slice(4)))
window.sessionStorage.setItem(`key-${this.ID}`, sjcl.codec.hex.fromBits(this.key))
}//}}}
}
export class KeyComponent extends Component {
render({ model }) {//{{{
let status = ''
switch(model.status()) {
case 'locked':
status = html`<div class="status locked"><img src="/images/${window._VERSION}/padlock-closed.svg" /></div>`
break
case 'unlocked':
status = html`<div class="status unlocked"><img src="/images/${window._VERSION}/padlock-open.svg" /></div>`
break
}
return html`
<div class="status" onclick=${()=>this.toggle()}>${status}</div>
<div class="description" onclick=${()=>this.toggle()}>${model.description}</div>
`
}//}}}
toggle() {//{{{
if(this.props.model.status() == 'locked')
this.unlock()
else
this.lock()
}//}}}
lock() {//{{{
this.props.model.lock()
this.forceUpdate()
}//}}}
unlock() {//{{{
let pass = prompt("Password")
if(!pass)
return
try {
this.props.model.unlock(pass)
this.forceUpdate()
} catch(err) {
alert(err)
}
}//}}}
}
// vim: foldmethod=marker

2574
static/js/lib/sjcl.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import { h, Component, createRef } from 'preact'
import htm from 'htm'
import { signal } from 'preact/signals'
import { Keys, Key } from 'key'
const html = htm.bind(h)
export class NodeUI extends Component {
@ -9,8 +10,9 @@ export class NodeUI extends Component {
this.menu = signal(false)
this.node = signal(null)
this.nodeContent = createRef()
this.upload = signal(false)
this.keys = signal([])
this.page = signal('node')
window.addEventListener('popstate', evt=>{
if(evt.state && evt.state.hasOwnProperty('nodeID'))
this.goToNode(evt.state.nodeID, true)
@ -35,8 +37,8 @@ export class NodeUI extends Component {
).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;
if(a.Name.toLowerCase() > b.Name.toLowerCase()) return 1
if(a.Name.toLowerCase() < b.Name.toLowerCase()) return -1
return 0
}).map(child=>html`
<div class="child-node" onclick=${()=>this.goToNode(child.ID)}>${child.Name}</div>
@ -44,23 +46,46 @@ export class NodeUI extends Component {
let modified = ''
if(this.props.app.nodeModified.value)
modified = 'modified';
modified = 'modified'
let upload = '';
if(this.upload.value)
upload = html`<${UploadUI} nodeui=${this} />`
let menu = '';
// Page to display
let page = ''
switch(this.page.value) {
case 'node':
if(node.ID > 0)
page = html`
${children.length > 0 ? html`<div class="child-nodes">${children}</div>` : html``}
<div class="node-name">${node.Name}</div>
<${NodeContent} key=${node.ID} content=${node.Content} 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)
upload = html`<${Menu} nodeui=${this} />`
menu = html`<${Menu} nodeui=${this} />`
return html`
${menu}
${upload}
<header class="${modified}" onclick=${()=>this.saveNode()}>
<div class="tree"><img src="/images/${window._VERSION}/tree.svg" onclick=${()=>document.getElementById('app').classList.toggle('toggle-tree')} /></div>
<div class="name">Notes</div>
<div class="add" onclick=${evt=>this.createNode(evt)}>+</div>
<div class="keys" onclick=${()=>this.showPage('keys')}><img src="/images/${window._VERSION}/padlock.svg" /></div>
<div class="menu" onclick=${evt=>this.showMenu(evt)}></div>
</header>
@ -68,14 +93,7 @@ export class NodeUI extends Component {
<div class="crumbs">${crumbs}</crumbs>
</div>
${children.length > 0 ? html`<div class="child-nodes">${children}</div>` : html``}
${node.ID > 0 ? html`
<div class="node-name">${node.Name}</div>
<${NodeContent} key=${node.ID} content=${node.Content} ref=${this.nodeContent} />
` : html``}
<${NodeFiles} node=${this.node.value} />
${page}
`
}//}}}
componentDidMount() {//{{{
@ -91,6 +109,27 @@ export class NodeUI extends Component {
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()
@ -98,16 +137,10 @@ export class NodeUI extends Component {
handled = false
break
case 'N':
if((evt.ctrlKey && evt.AltKey) || (evt.shiftKey && evt.altKey))
this.createNode()
else
handled = false
break
case 'U':
if((evt.ctrlKey && evt.altKey) || (evt.shiftKey && evt.altKey))
this.upload.value = true
if(evt.shiftKey && evt.altKey)
this.showPage('upload')
else
handled = false
@ -151,7 +184,7 @@ export class NodeUI extends Component {
// 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');
document.getElementById('app').classList.remove('toggle-tree')
})
}//}}}
createNode(evt) {//{{{
@ -184,6 +217,10 @@ export class NodeUI extends Component {
this.menu.value = false
})
}//}}}
showPage(pg) {//{{{
this.page.value = pg
}//}}}
}
class NodeContent extends Component {
@ -342,12 +379,12 @@ export class Node {
})
.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
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
})
}//}}}
}
@ -358,7 +395,8 @@ class Menu extends Component {
<div id="blackout" onclick=${()=>nodeui.menu.value = false}></div>
<div id="menu">
<div class="item" onclick=${()=>{ nodeui.renameNode(); nodeui.menu.value = false }}>Rename</div>
<div class="item separator" onclick=${()=>{ nodeui.deleteNode(); nodeui.menu.value = false }}>Delete</div>
<div class="item" onclick=${()=>{ nodeui.deleteNode(); nodeui.menu.value = false }}>Delete</div>
<div class="item separator" onclick=${()=>{ nodeui.showPage('properties'); nodeui.menu.value = false }}>Properties</div>
<div class="item separator" onclick=${()=>{ nodeui.upload.value = true; nodeui.menu.value = false }}>Upload</div>
<div class="item" onclick=${()=>{ nodeui.logout(); nodeui.menu.value = false }}>Log out</div>
</div>
@ -463,6 +501,19 @@ class UploadUI extends Component {
request.send(formdata)
}//}}}
}
class NodeProperties extends Component {
constructor(props) {//{{{
super(props)
}//}}}
render({ nodeui }) {//{{{
return html`
<div id="blackout" onclick=${()=>nodeui.properties.value = false}></div>
<div id="properties">
<h1>Node properties</h1>
<input type="checkbox" id="node-encrypted" /> <label for="node-encrypted">Encrypted</label>
</div>
`
}//}}}
}
// vim: foldmethod=marker

View File

@ -23,29 +23,8 @@ html, body {
}
h1 {
color: @accent_1;
}
.layout-crumbs {
grid-template-areas:
"header"
"crumbs"
"child-nodes"
"name"
"content"
"files"
"blank"
;
grid-template-columns: 1fr;
grid-template-rows:
min-content /* header */
min-content /* crumbs */
min-content /* child-nodes */
min-content /* name */
min-content /* content */
min-content /* files */
1fr; /* blank */
#tree { display: none }
margin-top: 0px;
font-size: 1em;
}
#blackout {
@ -145,10 +124,23 @@ h1 {
}
}
#properties {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #333;
background: #fff;
border: 2px solid #000;
padding: 16px;
z-index: 1025;
}
header {
display: grid;
grid-area: header;
grid-template-columns: min-content 1fr min-content min-content;
grid-template-columns: min-content 1fr repeat(3, min-content);
align-items: center;
//background: @accent_1;
padding: 0px;
@ -186,6 +178,15 @@ header {
cursor: pointer;
}
.keys {
padding-right: 16px;
img {
cursor: pointer;
height: 24px;
}
}
.menu {
font-size: 1.25em;
padding-right: 16px;
@ -415,6 +416,35 @@ header {
}
}
#keys {
padding: 32px;
color: #333;
.key-list {
display: grid;
grid-template-columns: min-content 1fr;
grid-gap: 12px 12px;
align-items: end;
margin-top: 16px;
font-size: 0.85em;
.status {
cursor: pointer;
img {
height: 32px;
}
.locked { color: #a00 }
.unlocked { color: #0a0 }
}
.description {
cursor: pointer;
padding-bottom: 4px;
}
}
}
.layout-tree {// {{{
display: grid;
grid-template-areas:
@ -479,6 +509,27 @@ header {
1fr; /* blank */
#tree { display: none }
}// }}}
.layout-keys {
display: grid;
grid-template-areas:
"header"
"keys"
;
grid-template-columns: 1fr;
grid-template-rows:
min-content /* header */
1fr; /* blank */
color: #fff;
height: 100%;
#crumbs { display: none }
.child-nodes { display: none }
.node-name { display: none }
.grow-wrap { display: none }
#file-section { display: none }
#tree { display: none }
#keys { display: block }
}
#app {
.layout-tree();