This commit is contained in:
Magnus Åhall 2024-11-29 09:15:42 +01:00
parent bd4a475923
commit 9a164b984a
36 changed files with 2500 additions and 77 deletions

View File

@ -189,7 +189,7 @@ func (mngr *Manager) Authenticate(username, password string) (authenticated bool
} // }}} } // }}}
func (mngr *Manager) CreateUser(username, password, name string) (alreadyExists bool, err error) { // {{{ func (mngr *Manager) CreateUser(username, password, name string) (alreadyExists bool, err error) { // {{{
_, err = mngr.db.Exec(` _, err = mngr.db.Exec(`
INSERT INTO public.user(username, password, name, totp) INSERT INTO public.user(username, password, name)
VALUES( VALUES(
$1, $1,
public.password_hash( public.password_hash(
@ -199,8 +199,7 @@ func (mngr *Manager) CreateUser(username, password, name string) (alreadyExists
/* password */ /* password */
$2::bytea $2::bytea
), ),
$3, $3
''
) )
`, `,
username, username,

22
main.go
View File

@ -163,6 +163,10 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
Webengine.StaticResource(w, r) Webengine.StaticResource(w, r)
} // }}} } // }}}
func httpError(w http.ResponseWriter, err error) {// {{{
j, _ := json.Marshal(struct { OK bool; Error string }{false, err.Error()})
w.Write(j)
}// }}}
func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{ func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{
w.Header().Add("Content-Type", "text/javascript; charset=utf-8") w.Header().Add("Content-Type", "text/javascript; charset=utf-8")
@ -209,13 +213,19 @@ func pageNotes2(w http.ResponseWriter, r *http.Request) { // {{{
} // }}} } // }}}
func actionNodeTree(w http.ResponseWriter, r *http.Request) { // {{{ func actionNodeTree(w http.ResponseWriter, r *http.Request) { // {{{
user, _ := r.Context().Value(CONTEXT_USER).(User) user := getUser(r)
nodes, err := NodeTree(user.ID, 0)
if err != nil {
httpError(w, err)
return
}
j, _ := json.Marshal(struct { j, _ := json.Marshal(struct {
OK bool OK bool
Foo string Nodes []Node
User User }{true, nodes})
}{true, "FOO", user}) Log.Debug("tree", "nodes", nodes)
w.Write(j) w.Write(j)
} // }}} } // }}}
@ -261,3 +271,7 @@ func changePassword(username string) { // {{{
fmt.Printf("\nPassword changed\n") fmt.Printf("\nPassword changed\n")
} // }}} } // }}}
func getUser(r *http.Request) User { // {{{
user, _ := r.Context().Value(CONTEXT_USER).(User)
return user
} // }}}

View File

@ -1,43 +1,29 @@
CREATE TABLE public.user ( CREATE TABLE public."user" (
id serial NOT NULL, id SERIAL NOT NULL,
"name" varchar NOT NULL, username VARCHAR NOT NULL,
"username" varchar NOT NULL, name VARCHAR NOT NULL,
"password" char(96) NOT NULL, "password" VARCHAR NOT NULL,
totp varchar NOT NULL, last_login TIMESTAMP NOT NULL DEFAULT now(),
last_login timestamp with time zone NOT NULL DEFAULT '1970-01-01 00:00:00', CONSTRAINT newtable_pk PRIMARY KEY (id)
CONSTRAINT user_pk PRIMARY KEY (id),
CONSTRAINT user_un UNIQUE (username)
); );
CREATE TABLE public.session ( CREATE TABLE public."session" (
id serial NOT NULL, uuid UUID NOT NULL,
user_id int4 NULL, user_id INT4 NULL,
"uuid" char(36) NOT NULL, created TIMESTAMP NOT NULL DEFAULT now(),
created timestamp with time zone NOT NULL DEFAULT NOW(), CONSTRAINT session_pk PRIMARY KEY (uuid),
last_used timestamp with time zone NOT NULL DEFAULT NOW(), CONSTRAINT user_session_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
CONSTRAINT session_pk PRIMARY KEY (id),
CONSTRAINT session_un UNIQUE ("uuid"),
CONSTRAINT session_user_fk FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE ON UPDATE CASCADE
); );
CREATE EXTENSION IF NOT EXISTS pgcrypto SCHEMA public; CREATE TABLE public.node (
id SERIAL NOT NULL,
user_id INT4 NOT NULL,
parent_id INT4 NULL,
"name" VARCHAR(256) NOT NULL DEFAULT '',
"content" TEXT NOT NULL DEFAULT '',
CONSTRAINT name_length CHECK (LENGTH(TRIM(name)) > 0),
CONSTRAINT node_pk PRIMARY KEY (id),
CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT node_fk FOREIGN KEY (parent_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT
);
CREATE FUNCTION password_hash(salt_hex char(32), pass bytea)
RETURNS char(96)
LANGUAGE plpgsql
AS
$$
BEGIN
RETURN (
SELECT
salt_hex ||
encode(
sha256(
decode(salt_hex, 'hex') || /* salt in binary */
pass /* password */
),
'hex'
)
);
END;
$$;

View File

@ -1,33 +1,4 @@
CREATE EXTENSION pg_trgm; ALTER TABLE node ADD COLUMN updated TIMESTAMP NOT NULL DEFAULT NOW();
CREATE TABLE public.crypto_key (
id serial4 NOT NULL,
user_id int4 NOT NULL,
description varchar(255) DEFAULT ''::character varying NOT NULL,
"key" bpchar(144) NOT NULL,
CONSTRAINT crypto_key_pk PRIMARY KEY (id),
CONSTRAINT crypto_user_description_un UNIQUE (user_id, description),
CONSTRAINT crypto_key_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT
);
CREATE TABLE public.node (
id serial4 NOT NULL,
user_id int4 NOT NULL,
parent_id int4 NULL,
"name" varchar(256) DEFAULT ''::character varying NOT NULL,
"content" text DEFAULT ''::text NOT NULL,
updated timestamptz DEFAULT now() NOT NULL,
crypto_key_id int4 NULL,
content_encrypted text DEFAULT ''::text NOT NULL,
markdown bool DEFAULT false NOT NULL,
CONSTRAINT name_length CHECK ((length(TRIM(BOTH FROM name)) > 0)),
CONSTRAINT node_pk PRIMARY KEY (id),
CONSTRAINT crypto_key_fk FOREIGN KEY (crypto_key_id) REFERENCES public.crypto_key(id) ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT node_fk FOREIGN KEY (parent_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT,
CONSTRAINT node_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT
);
CREATE INDEX node_search_index ON public.node USING gin (name gin_trgm_ops, content gin_trgm_ops);
CREATE OR REPLACE FUNCTION node_update_timestamp() CREATE OR REPLACE FUNCTION node_update_timestamp()
RETURNS TRIGGER RETURNS TRIGGER

10
sql/00003.sql Normal file
View File

@ -0,0 +1,10 @@
CREATE TABLE public.file (
id serial NOT NULL,
user_id int4 NOT NULL,
filename varchar(256) NOT NULL DEFAULT '<noname>',
"size" int4 NOT NULL DEFAULT 0,
mime varchar(256) NOT NULL DEFAULT '',
uploaded timestamp NOT NULL DEFAULT NOW(),
CONSTRAINT file_pk PRIMARY KEY (id),
CONSTRAINT file_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT
);

1
sql/00004.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE file ADD COLUMN md5 CHAR(32) DEFAULT ''

2
sql/00005.sql Normal file
View File

@ -0,0 +1,2 @@
ALTER TABLE public.file ADD node_id int4 NOT NULL;
ALTER TABLE public.file ADD CONSTRAINT file_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT;

5
sql/00006.sql Normal file
View File

@ -0,0 +1,5 @@
ALTER TABLE public.file DROP CONSTRAINT file_node_fk;
ALTER TABLE public.file ADD CONSTRAINT file_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE public.file DROP CONSTRAINT file_fk;
ALTER TABLE public.file ADD CONSTRAINT file_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE;

10
sql/00007.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)]';

2
sql/00008.sql Normal file
View File

@ -0,0 +1,2 @@
ALTER TABLE public.node ADD crypto_key_id int4 NULL;
ALTER TABLE public.node ADD CONSTRAINT crypto_key_fk FOREIGN KEY (crypto_key_id) REFERENCES public.crypto_key(id) ON DELETE RESTRICT ON UPDATE RESTRICT;

1
sql/00009.sql Normal file
View File

@ -0,0 +1 @@
CREATE SEQUENCE aes_ccm_counter AS int8 INCREMENT BY 1 NO CYCLE;

1
sql/00010.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE public.crypto_key ADD CONSTRAINT crypto_user_description_un UNIQUE (user_id, description);

5
sql/00011.sql Normal file
View File

@ -0,0 +1,5 @@
ALTER TABLE node ADD COLUMN content_encrypted TEXT NOT NULL DEFAULT '';
UPDATE node SET content_encrypted = content, content = '' WHERE crypto_key_id IS NOT NULL;
CREATE EXTENSION pg_trgm;
CREATE INDEX node_content_index ON node USING gin (content gin_trgm_ops);

2
sql/00012.sql Normal file
View File

@ -0,0 +1,2 @@
DROP INDEX node_content_index;
CREATE INDEX node_search_index ON node USING gin (name gin_trgm_ops, content gin_trgm_ops);

1
sql/00013.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE public.node ADD COLUMN markdown bool NOT NULL DEFAULT false;

18
sql/00014.sql Normal file
View File

@ -0,0 +1,18 @@
CREATE TABLE checklist_group (
id serial NOT NULL,
node_id int4 NOT NULL,
"order" int NOT NULL DEFAULT 0,
label varchar NOT NULL,
CONSTRAINT checklist_group_pk PRIMARY KEY (id),
CONSTRAINT checklist_group_node_fk FOREIGN KEY (node_id) REFERENCES public."node"(id) ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE checklist_item (
id serial NOT NULL,
checklist_group_id int4 NOT NULL,
"order" int NOT NULL DEFAULT 0,
label varchar NOT NULL,
checked bool NOT NULL DEFAULT false,
CONSTRAINT checklist_item_pk PRIMARY KEY (id),
CONSTRAINT checklist_group_item_fk FOREIGN KEY (checklist_group_id) REFERENCES public."checklist_group"(id) ON DELETE CASCADE ON UPDATE CASCADE
)

14
sql/00015.sql Normal file
View File

@ -0,0 +1,14 @@
CREATE TABLE public.schedule (
id SERIAL NOT NULL,
user_id INT4 NOT NULL,
node_id INT4 NOT NULL,
schedule_uuid CHAR(36) DEFAULT GEN_RANDOM_UUID() NOT NULL,
"time" TIMESTAMP NOT NULL,
description VARCHAR DEFAULT '' NOT NULL,
acknowledged BOOL DEFAULT false NOT NULL,
CONSTRAINT schedule_pk PRIMARY KEY (id),
CONSTRAINT schedule_uuid UNIQUE (schedule_uuid),
CONSTRAINT schedule_node_fk FOREIGN KEY (node_id) REFERENCES public.node(id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT schedule_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
);

1
sql/00016.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE public.schedule ADD CONSTRAINT schedule_event UNIQUE (user_id, node_id, "time", description);

11
sql/00017.sql Normal file
View File

@ -0,0 +1,11 @@
CREATE TABLE public.notification (
id SERIAl NOT NULL,
user_id INT4 NOT NULL,
service VARCHAR DEFAULT 'NTFY' NOT NULL,
"configuration" JSONB DEFAULT '{}' NOT NULL,
prio INT DEFAULT 0 NOT NULL,
CONSTRAINT notification_pk PRIMARY KEY (id),
CONSTRAINT notification_unique UNIQUE (user_id,prio),
CONSTRAINT notification_user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE
);

2
sql/00018.sql Normal file
View File

@ -0,0 +1,2 @@
ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamptz USING "time"::timestamptz;

1
sql/00019.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE public.schedule ADD COLUMN remind_minutes int NOT NULL DEFAULT 0;

2
sql/00020.sql Normal file
View File

@ -0,0 +1,2 @@
ALTER TABLE public."user" ADD timezone varchar DEFAULT 'UTC' NOT NULL;
ALTER TABLE public.schedule ALTER COLUMN "time" TYPE timestamp USING "time"::timestamp;

1
sql/00021.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE public.node ALTER COLUMN updated TYPE timestamptz USING updated::timestamptz;

21
sql/00022.sql Normal file
View File

@ -0,0 +1,21 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE FUNCTION public.password_hash(salt_hex char(32), pass bytea)
RETURNS char(96)
LANGUAGE plpgsql
AS
$$
BEGIN
RETURN (
SELECT
salt_hex ||
encode(
sha256(
decode(salt_hex, 'hex') || /* salt in binary */
pass /* password */
),
'hex'
)
);
END;
$$;

38
static/css/notes2.css Normal file
View File

@ -0,0 +1,38 @@
#tree {
grid-area: tree;
padding: 16px;
background-color: #333;
color: #ddd;
z-index: 100;
}
#tree .node {
display: grid;
grid-template-columns: 24px min-content;
grid-template-rows: min-content 1fr;
margin-top: 12px;
}
#tree .node .expand-toggle img {
width: 16px;
height: 16px;
}
#tree .node .name {
white-space: nowrap;
cursor: pointer;
user-select: none;
}
#tree .node .name:hover {
color: #fe5f55;
}
#tree .node .name.selected {
color: #fe5f55;
font-weight: bold;
}
#tree .node .children {
padding-left: 24px;
margin-left: 8px;
border-left: 1px solid #555;
grid-column: 1 / -1;
}
#tree .node .children.collapsed {
display: none;
}

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="399.99997"
height="399.99997"
viewBox="0 0 105.83332 105.83333"
version="1.1"
id="svg8"
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
sodipodi:docname="collapsed.svg"
xml:space="preserve"
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" /><sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.4142136"
inkscape:cx="304.05591"
inkscape:cy="298.39905"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1916"
inkscape:window-height="1404"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d6d6d6"
showborder="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(-42.756321,-24.613384)"><rect
style="color:#000000;overflow:visible;fill:#537979;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
id="rect5470"
width="105.83333"
height="105.83333"
x="42.756321"
y="24.613384"
rx="21.166666"
ry="21.166666" /><rect
style="color:#000000;overflow:visible;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
id="rect6360"
width="63.5"
height="18.520834"
x="63.922985"
y="68.26963"
rx="5.2916665"
ry="5.2916665" /><rect
style="color:#000000;overflow:visible;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
id="rect10171"
width="63.5"
height="18.520834"
x="-109.28004"
y="86.412567"
rx="5.2916665"
ry="5.2916665"
transform="rotate(-90)" /></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="399.99997"
height="399.99997"
viewBox="0 0 105.83332 105.83333"
version="1.1"
id="svg8"
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
sodipodi:docname="expanded.svg"
xml:space="preserve"
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" /><sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.4142136"
inkscape:cx="304.05591"
inkscape:cy="298.39905"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1916"
inkscape:window-height="1404"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d6d6d6"
showborder="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(-42.756321,-24.613384)"><rect
style="color:#000000;overflow:visible;fill:#537979;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
id="rect5470"
width="105.83333"
height="105.83333"
x="42.756321"
y="24.613384"
rx="21.166666"
ry="21.166666" /><rect
style="color:#000000;overflow:visible;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
id="rect6360"
width="63.5"
height="18.520834"
x="63.922985"
y="68.26963"
rx="5.2916665"
ry="5.2916665" /></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

57
static/images/leaf.svg Normal file
View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="399.99997"
height="399.99997"
viewBox="0 0 105.83332 105.83333"
version="1.1"
id="svg8"
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
sodipodi:docname="leaf.svg"
xml:space="preserve"
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" /><sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.4142136"
inkscape:cx="303.34881"
inkscape:cy="297.69195"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1916"
inkscape:window-height="1404"
inkscape:window-x="0"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d6d6d6"
showborder="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(-42.756321,-24.613384)"><rect
style="color:#000000;overflow:visible;fill:#555555;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.264583;paint-order:markers stroke fill;stop-color:#000000"
id="rect5470"
width="105.83333"
height="105.83333"
x="42.756321"
y="24.613384"
rx="21.166666"
ry="21.166666" /></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,23 +1,172 @@
import { h, Component, createRef } from 'preact' import { h, Component, createRef } from 'preact'
import { signal } from 'preact/signals'
import htm from 'htm' import htm from 'htm'
import { API } from 'api' import { API } from 'api'
import { Node } from 'node'
const html = htm.bind(h) const html = htm.bind(h)
export class Notes2 { export class Notes2 {
constructor() {//{{{ constructor() {//{{{
this.startNode = null
this.setStartNode()
}//}}} }//}}}
render() {//{{{ render() {//{{{
return html` return html`
<button onclick=${()=>API.logout()}>Log out</button> <button onclick=${()=>API.logout()}>Log out</button>
<${Tree} app=${this} />
` `
}//}}} }//}}}
setStartNode() {//{{{
let urlParams = new URLSearchParams(window.location.search)
let nodeID = urlParams.get('node')
this.startNode = new Node(this, nodeID ? parseInt(nodeID) : 0)
}//}}}
treeGet() { treeGet() {
const req = {} const req = {}
API.query('POST', '/tree/get', req) API.query('POST', '/node/tree', req)
.then(response => { .then(response => {
console.log(response) console.log(response)
}) })
.catch(e => console.log(e.type, e.error)) .catch(e => console.log(e.type, e.error))
} }
} }
class Tree extends Component {
constructor(props) {//{{{
super(props)
this.treeNodes = {}
this.treeNodeComponents = {}
this.treeTrunk = []
this.selectedTreeNode = null
this.retrieve()
}//}}}
render({ app }) {//{{{
let renderedTreeTrunk = this.treeTrunk.map(node => {
this.treeNodeComponents[node.ID] = createRef()
return html`<${TreeNode} key=${"treenode_" + node.ID} tree=${this} node=${node} ref=${this.treeNodeComponents[node.ID]} selected=${node.ID == app.startNode.ID} />`
})
return html`<div id="tree">${renderedTreeTrunk}</div>`
}//}}}
retrieve(callback = null) {//{{{
const req = { StartNodeID: 0 }
API.query('POST', '/node/tree', req)
.then(res => {
this.treeNodes = {}
this.treeNodeComponents = {}
this.treeTrunk = []
this.selectedTreeNode = null
// A tree of nodes is built. This requires the list of nodes
// returned from the server to be sorted in such a way that
// a parent node always appears before a child node.
// The server uses a recursive SQL query delivering this.
res.Nodes.forEach(nodeData => {
let node = new Node(
this,
nodeData.ID,
)
node.Children = []
node.Crumbs = []
node.Files = []
node.Level = nodeData.Level
node.Name = nodeData.Name
node.ParentID = nodeData.ParentID
node.Updated = nodeData.Updated
node.UserID = nodeData.UserID
this.treeNodes[node.ID] = node
if (node.ParentID == 0)
this.treeTrunk.push(node)
else if (this.treeNodes[node.ParentID] !== undefined)
this.treeNodes[node.ParentID].Children.push(node)
})
// When starting with an explicit node value, expanding all nodes
// on its path gives the user a sense of location. Not necessarily working
// as the start node isn't guaranteed to have returned data yet.
// XXX this.crumbsUpdateNodes()
this.forceUpdate()
if (callback)
callback()
})
.catch(e => { console.log(e); console.log(e.type, e.error); alert(e.error) })
}//}}}
setSelected(node) {//{{{
if (this.selectedTreeNode)
this.selectedTreeNode.selected.value = false
this.selectedTreeNode = this.treeNodeComponents[node.ID].current
this.selectedTreeNode.selected.value = true
this.selectedTreeNode.expanded.value = true
this.expandToTrunk(node.ID)
}//}}}
crumbsUpdateNodes(node) {//{{{
this.props.app.startNode.Crumbs.forEach(crumb => {
// Start node is loaded before the tree.
let node = this.treeNodes[crumb.ID]
if (node)
node._expanded = true
// Tree is done before the start node.
let component = this.treeNodeComponents[crumb.ID]
if (component && component.current)
component.current.expanded.value = true
})
// Will be undefined when called from tree initialization
// (as tree nodes aren't rendered yet)
if (node !== undefined)
this.setSelected(node)
}//}}}
expandToTrunk(nodeID) {//{{{
let node = this.treeNodes[nodeID]
if (node === undefined)
return
node = this.treeNodes[node.ParentID]
while (node !== undefined) {
this.treeNodeComponents[node.ID].current.expanded.value = true
node = this.treeNodes[node.ParentID]
}
}//}}}
}
class TreeNode extends Component {
constructor(props) {//{{{
super(props)
this.selected = signal(props.selected)
this.expanded = signal(this.props.node._expanded)
}//}}}
render({ tree, node }) {//{{{
let children = node.Children.map(node => {
tree.treeNodeComponents[node.ID] = createRef()
return html`<${TreeNode} key=${"treenode_" + node.ID} tree=${tree} node=${node} ref=${tree.treeNodeComponents[node.ID]} selected=${node.ID == tree.props.app.startNode.ID} />`
})
let expandImg = ''
if (node.Children.length == 0)
expandImg = html`<img src="/images/${window._VERSION}/leaf.svg" />`
else {
if (this.expanded.value)
expandImg = html`<img src="/images/${window._VERSION}/expanded.svg" />`
else
expandImg = html`<img src="/images/${window._VERSION}/collapsed.svg" />`
}
let selected = (this.selected.value ? 'selected' : '')
return html`
<div class="node">
<div class="expand-toggle" onclick=${() => this.expanded.value ^= true}>${expandImg}</div>
<div class="name ${selected}" onclick=${() => window._app.current.nodeUI.current.goToNode(node.ID)}>${node.Name}</div>
<div class="children ${node.Children.length > 0 && this.expanded.value ? 'expanded' : 'collapsed'}">${children}</div>
</div>`
}//}}}
}

472
static/js/checklist.mjs Normal file
View File

@ -0,0 +1,472 @@
import { h, Component, createRef } from 'preact'
import htm from 'htm'
import { signal } from 'preact/signals'
const html = htm.bind(h)
export class ChecklistGroup {
static sort(a, b) {//{{{
if (a.Order < b.Order) return -1
if (a.Order > b.Order) return 1
return 0
}//}}}
constructor(data) {//{{{
Object.keys(data).forEach(key => {
if (key == 'Items')
this.items = data[key].map(itemData => {
let item = new ChecklistItem(itemData)
item.checklistGroup = this
return item
})
else
this[key] = data[key]
})
}//}}}
addItem(label, okCallback) {//{{{
window._app.current.request('/node/checklist_group/item_add', {
ChecklistGroupID: this.ID,
Label: label,
})
.then(json => {
let item = new ChecklistItem(json.Item)
item.checklistGroup = this
this.items.push(item)
okCallback()
})
.catch(window._app.current.responseError)
return
}//}}}
updateLabel(newLabel, okCallback, errCallback) {//{{{
window._app.current.request('/node/checklist_group/label', {
ChecklistGroupID: this.ID,
Label: newLabel,
})
.then(okCallback)
.catch(errCallback)
}//}}}
delete(okCallback, errCallback) {//{{{
window._app.current.request('/node/checklist_group/delete', {
ChecklistGroupID: this.ID,
})
.then(() => {
okCallback()
})
.catch(errCallback)
}//}}}
}
export class ChecklistItem {
static sort(a, b) {//{{{
if (a.Order < b.Order) return -1
if (a.Order > b.Order) return 1
return 0
}//}}}
constructor(data) {//{{{
Object.keys(data).forEach(key => {
this[key] = data[key]
})
}//}}}
updateState(newState, okCallback, errCallback) {//{{{
window._app.current.request('/node/checklist_item/state', {
ChecklistItemID: this.ID,
State: newState,
})
.then(okCallback)
.catch(errCallback)
}//}}}
updateLabel(newLabel, okCallback, errCallback) {//{{{
window._app.current.request('/node/checklist_item/label', {
ChecklistItemID: this.ID,
Label: newLabel,
})
.then(okCallback)
.catch(errCallback)
}//}}}
delete(okCallback, errCallback) {//{{{
window._app.current.request('/node/checklist_item/delete', {
ChecklistItemID: this.ID,
})
.then(() => {
this.checklistGroup.items = this.checklistGroup.items.filter(item => item.ID != this.ID)
okCallback()
})
.catch(errCallback)
}//}}}
move(to, okCallback) {//{{{
window._app.current.request('/node/checklist_item/move', {
ChecklistItemID: this.ID,
AfterItemID: to.ID,
})
.then(okCallback)
.catch(_app.current.responseError)
}//}}}
}
export class Checklist extends Component {
constructor() {//{{{
super()
this.edit = signal(false)
this.dragItemSource = null
this.dragItemTarget = null
this.groupElements = {}
this.state = {
confirmDeletion: true,
continueAddingItems: true,
}
window._checklist = this
}//}}}
render({ ui, groups }, { confirmDeletion, continueAddingItems }) {//{{{
this.groupElements = {}
if (groups.length == 0 && !ui.node.value.ShowChecklist.value)
return
if (typeof groups.sort != 'function')
groups = []
groups.sort(ChecklistGroup.sort)
let groupElements = groups.map(group => {
this.groupElements[group.ID] = createRef()
return html`<${ChecklistGroupElement} ref=${this.groupElements[group.ID]} key="group-${group.ID}" ui=${this} group=${group} />`
})
let edit = 'edit-list-gray.svg'
let confirmDeletionEl = ''
if (this.edit.value) {
edit = 'edit-list.svg'
confirmDeletionEl = html`
<div>
<input type="checkbox" id="confirm-checklist-delete" checked=${confirmDeletion} onchange=${() => this.setState({ confirmDeletion: !confirmDeletion })} />
<label for="confirm-checklist-delete">Confirm checklist deletion</label>
</div>
<div>
<input type="checkbox" id="continue-adding-items" checked=${continueAddingItems} onchange=${() => this.setState({ continueAddingItems: !continueAddingItems })} />
<label for="continue-adding-items">Continue adding items</label>
</div>
`
}
let addGroup = () => {
if (this.edit.value)
return html`<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addGroup()} />`
}
return html`
<div id="checklist">
<div class="header">
<h1>Checklist</h1>
<img src="/images/${_VERSION}/${edit}" onclick=${() => this.toggleEdit()} />
<${addGroup} />
</div>
${confirmDeletionEl}
${groupElements}
</div>
`
}//}}}
toggleEdit() {//{{{
this.edit.value = !this.edit.value
}//}}}
addGroup() {//{{{
let label = prompt("Create a new group")
if (label === null)
return
label = label.trim()
if (label == '')
return
window._app.current.request('/node/checklist_group/add', {
NodeID: window._app.current.nodeUI.current.node.value.ID,
Label: label,
})
.then(json => {
let group = new ChecklistGroup(json.Group)
this.props.groups.push(group)
this.forceUpdate()
})
.catch(window._app.current.responseError)
return
}//}}}
dragTarget(target) {//{{{
if (this.dragItemTarget)
this.dragItemTarget.setDragTarget(false)
this.dragItemTarget = target
target.setDragTarget(true)
}//}}}
dragReset() {//{{{
if (this.dragItemTarget) {
this.dragItemTarget.setDragTarget(false)
this.dragItemTarget = null
}
}//}}}
}
class InputElement extends Component {
render({ placeholder, label }) {//{{{
return html`
<dialog id="input-text">
<div class="container">
<div class="label">${label}</div>
<input id="input-text-el" type="text" placeholder=${placeholder} />
<div class="buttons">
<div></div>
<button onclick=${()=>this.cancel()}>Cancel</button>
<button onclick=${()=>this.ok()}>OK</button>
</div>
</div>
</dialog>
`
}//}}}
componentDidMount() {//{{{
const dlg = document.getElementById('input-text')
const input = document.getElementById('input-text-el')
dlg.showModal()
dlg.addEventListener("keydown", evt => this.keyhandler(evt))
input.addEventListener("keydown", evt => this.keyhandler(evt))
input.focus()
}//}}}
ok() {//{{{
const input = document.getElementById('input-text-el')
this.props.callback(true, input.value)
}//}}}
cancel() {//{{{
this.props.callback(false)
}//}}}
keyhandler(evt) {//{{{
let handled = true
switch (evt.key) {
case 'Enter':
this.ok()
break;
case 'Escape':
this.cancel()
break;
default:
handled = false
}
if (handled) {
evt.stopPropagation()
evt.preventDefault()
}
}//}}}
}
class ChecklistGroupElement extends Component {
constructor() {//{{{
super()
this.label = createRef()
this.addingItem = signal(false)
}//}}}
render({ ui, group }) {//{{{
let items = ({ ui, group }) =>
group.items
.sort(ChecklistItem.sort)
.map(item => html`<${ChecklistItemElement} key="item-${item.ID}" ui=${ui} group=${this} item=${item} />`)
let label = () => html`<div class="label" style="cursor: pointer" ref=${this.label} onclick=${() => this.editLabel()}>${group.Label}</div>`
let addItem = () => {
if (this.addingItem.value)
return html`<${InputElement} label="New item" callback=${(ok, val) => this.addItem(ok, val)} />`
}
return html`
<${addItem} />
<div class="checklist-group-container">
<div class="checklist-group ${ui.edit.value ? 'edit' : ''}">
<div class="reorder" style="cursor: grab"></div>
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
<${label} />
<img src="/images/${_VERSION}/add-gray.svg" onclick=${() => this.addingItem.value = true} />
</div>
<${items} ui=${ui} group=${group} />
</div>
`
}//}}}
addItem(ok, label) {//{{{
if (!ok) {
this.addingItem.value = false
return
}
label = label.trim()
if (label == '') {
this.addingItem.value = false
return
}
this.props.group.addItem(label, () => {
this.forceUpdate()
})
if (!this.props.ui.state.continueAddingItems)
this.addingItem.value = false
}//}}}
editLabel() {//{{{
let label = prompt('Edit label', this.props.group.Label)
if (label === null)
return
label = label.trim()
if (label == '') {
alert(`A label can't be empty.`)
return
}
this.label.current.classList.remove('error')
this.props.group.updateLabel(label, () => {
this.props.group.Label = label
this.label.current.innerHTML = label
this.label.current.classList.add('ok')
this.forceUpdate()
setTimeout(() => this.label.current.classList.remove('ok'), 500)
}, () => {
this.label.current.classList.add('error')
})
}//}}}
delete() {//{{{
if (this.props.ui.state.confirmDeletion) {
if (!confirm(`Delete '${this.props.group.Label}'?`))
return
}
this.props.group.delete(() => {
this.props.ui.props.groups = this.props.ui.props.groups.filter(g => g.ID != this.props.group.ID)
this.props.ui.forceUpdate()
}, err => {
console.log(err)
console.log('error')
})
}//}}}
}
class ChecklistItemElement extends Component {
constructor(props) {//{{{
super(props)
this.state = {
checked: props.item.Checked,
dragTarget: false,
}
this.checkbox = createRef()
this.label = createRef()
}//}}}
render({ ui, item }, { checked, dragTarget }) {//{{{
let checkbox = () => {
if (ui.edit.value)
return html`<label ref=${this.label} onclick=${() => this.editLabel()} style="cursor: pointer">${item.Label}</label>`
else
return html`
<input type="checkbox" ref=${this.checkbox} key="checkbox-${item.ID}" id="checkbox-${item.ID}" checked=${checked} onchange=${evt => this.update(evt.target.checked)} />
<label ref=${this.label} for="checkbox-${item.ID}">${item.Label}</label>
`
}
return html`
<div class="checklist-item ${checked ? 'checked' : ''} ${ui.edit.value ? 'edit' : ''} ${dragTarget ? 'drag-target' : ''}" draggable=true>
<div class="reorder" style="user-select: none;"></div>
<img src="/images/${_VERSION}/trashcan.svg" onclick=${() => this.delete()} />
<${checkbox} />
</div>
`
}//}}}
componentDidMount() {//{{{
this.base.addEventListener('dragstart', evt => this.dragStart(evt))
this.base.addEventListener('dragend', () => this.dragEnd())
this.base.addEventListener('dragenter', evt => this.dragEnter(evt))
}//}}}
update(checked) {//{{{
this.setState({ checked })
this.checkbox.current.classList.remove('error')
this.props.item.updateState(checked, () => {
this.checkbox.current.classList.add('ok')
setTimeout(() => this.checkbox.current.classList.remove('ok'), 500)
}, () => {
this.checkbox.current.classList.add('error')
})
}//}}}
editLabel() {//{{{
let label = prompt('Edit label', this.props.item.Label)
if (label === null)
return
label = label.trim()
if (label == '') {
alert(`A label can't be empty.`)
return
}
this.label.current.classList.remove('error')
this.props.item.updateLabel(label, () => {
this.props.item.Label = label
this.label.current.innerHTML = label
this.label.current.classList.add('ok')
setTimeout(() => this.label.current.classList.remove('ok'), 500)
}, () => {
this.label.current.classList.add('error')
})
}//}}}
delete() {//{{{
if (this.props.ui.state.confirmDeletion) {
if (!confirm(`Delete '${this.props.item.Label}'?`))
return
}
this.props.item.delete(() => {
this.props.group.forceUpdate()
}, err => {
console.log(err)
console.log('error')
})
}//}}}
setDragTarget(state) {//{{{
this.setState({ dragTarget: state })
}//}}}
dragStart(evt) {//{{{
// Shouldn't be needed, but in case the previous drag was bungled up, we reset.
this.props.ui.dragReset()
this.props.ui.dragItemSource = this
const img = new Image();
evt.dataTransfer.setDragImage(img, 10, 10);
}//}}}
dragEnter(evt) {//{{{
evt.preventDefault()
this.props.ui.dragTarget(this)
}//}}}
dragEnd() {//{{{
let groups = this.props.ui.props.groups
let from = this.props.ui.dragItemSource.props.item
let to = this.props.ui.dragItemTarget.props.item
this.props.ui.dragReset()
if (from.ID == to.ID)
return
let fromGroup = groups.find(g => g.ID == from.GroupID)
let toGroup = groups.find(g => g.ID == to.GroupID)
from.Order = to.Order
from.GroupID = toGroup.ID
toGroup.items.forEach(i => {
if (i.ID == from.ID)
return
if (i.Order <= to.Order)
i.Order--
})
if (fromGroup.ID != toGroup.ID) {
fromGroup.items = fromGroup.items.filter(i => i.ID != from.ID)
toGroup.items.push(from)
}
this.props.ui.groupElements[fromGroup.ID].current.forceUpdate()
this.props.ui.groupElements[toGroup.ID].current.forceUpdate()
from.move(to, () => {})
}//}}}
}
// vim: foldmethod=marker

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

@ -0,0 +1,72 @@
export default class Crypto {
constructor(key) {//{{{
if(key === null)
throw new Error("No key provided")
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)
}
}//}}}
}
// vim: foldmethod=marker

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

@ -0,0 +1,241 @@
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.state = {
create: false,
}
props.nodeui.retrieveKeys()
}//}}}
render({ nodeui }, { create }) {//{{{
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} />`
)
let createButton = ''
let createComponents = ''
if(create) {
createComponents = html`
<div id="key-create">
<h2>New key</h2>
<div class="fields">
<input type="text" id="key-description" placeholder="Name" />
<input type="password" id="key-pass1" placeholder="Password" />
<input type="password" id="key-pass2" placeholder="Repeat password" />
<textarea id="key-key" placeholder="Key"></textarea>
<div>
<button class="generate" onclick=${()=>this.generateKey()}>Generate</button>
<button class="create" onclick=${()=>this.createKey()}>Create</button>
</div>
</div>
</div>
`
} else {
createButton = html`<div style="margin-top: 16px;"><button onclick=${()=>this.setState({ create: true })}>Create new key</button></div>`
}
return html`
<div id="keys">
<h1>Encryption keys</h1>
<p>
Unlock a key by clicking its name. Lock it by clicking it again.
</p>
<p>
Copy the key and store it in a very secure place to have a way to access notes
in case the password is forgotten, or database is corrupted.
</p>
<p>Click "View key" after unlocking it.</p>
${createButton}
${createComponents}
<h2>Keys</h2>
<div class="key-list">
${keys}
</div>
</div>`
}//}}}
generateKey() {//{{{
let keyTextarea = document.getElementById('key-key')
let key = sjcl.codec.hex.fromBits(Crypto.generate_key()).replace(/(....)/g, '$1 ').trim()
keyTextarea.value = key
}//}}}
validateNewKey() {//{{{
let keyDescription = document.getElementById('key-description').value
let keyTextarea = document.getElementById('key-key').value
let pass1 = document.getElementById('key-pass1').value
let pass2 = document.getElementById('key-pass2').value
if(keyDescription.trim() == '')
throw new Error('The key has to have a description')
if(pass1.trim() == '' || pass1.length < 4)
throw new Error('The password has to be at least 4 characters long.')
if(pass1 != pass2)
throw new Error(`Passwords doesn't match`)
let cleanKey = keyTextarea.replace(/\s+/g, '')
if(!cleanKey.match(/^[0-9a-f]{64}$/i))
throw new Error('Invalid key - has to be 64 characters of 0-9 and A-F')
}//}}}
createKey() {//{{{
try {
this.validateNewKey()
let description = document.getElementById('key-description').value
let keyAscii = document.getElementById('key-key').value
let pass1 = document.getElementById('key-pass1').value
// Key in hex taken from user.
let actual_key = sjcl.codec.hex.toBits(keyAscii.replace(/\s+/g, ''))
// Key generated from password, used to encrypt the actual key.
let pass_gen = Crypto.pass_to_key(pass1)
let crypto = new Crypto(pass_gen.key)
let encrypted_actual_key = crypto.encrypt(actual_key, 0x1n, false)
// Database value is salt + actual key, needed to generate the same key from the password.
let db_encoded = sjcl.codec.hex.fromBits(
pass_gen.salt.concat(encrypted_actual_key)
)
// Create on server.
window._app.current.request('/key/create', {
description,
key: db_encoded,
})
.then(res=>{
let key = new Key(res.Key, this.props.nodeui.keyCounter)
this.props.nodeui.keys.value = this.props.nodeui.keys.value.concat(key)
})
.catch(window._app.current.responseError)
} catch(err) {
alert(err.message)
return
}
}//}}}
}
export class Key {
constructor(data, counter_callback) {//{{{
this.ID = data.ID
this.description = data.Description
this.encryptedKey = data.Key
this.key = null
this._counter_cbk = counter_callback
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.encryptedKey)
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))
}//}}}
async counter() {//{{{
return this._counter_cbk()
}//}}}
}
export class KeyComponent extends Component {
constructor({ model }) {//{{{
super({ model })
this.state = {
show_key: false,
}
}//}}}
render({ model }, { show_key }) {//{{{
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
}
let hex_key = ''
if(show_key) {
if(model.status() == 'locked')
hex_key = html`<div class="hex-key">Unlock key first</div>`
else {
let key = sjcl.codec.hex.fromBits(model.key)
key = key.replace(/(....)/g, "$1 ").trim()
hex_key = html`<div class="hex-key">${key}</div>`
}
}
let unlocked = model.status()=='unlocked'
return html`
<div class="status" onclick=${()=>this.toggle()}>${status}</div>
<div class="description" onclick=${()=>this.toggle()}>${model.description}</div>
<div class="view" onclick=${()=>this.toggleViewKey()}>${unlocked ? 'View key' : ''}</div>
${hex_key}
`
}//}}}
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)
}
}//}}}
toggleViewKey() {//{{{
this.setState({ show_key: !this.state.show_key })
}//}}}
}
// vim: foldmethod=marker

1117
static/js/node.mjs Normal file

File diff suppressed because it is too large Load Diff

52
static/less/notes2.less Normal file
View File

@ -0,0 +1,52 @@
@import "theme.less";
#tree {
grid-area: tree;
padding: 16px;
background-color: #333;
color: #ddd;
z-index: 100; // Over crumbs shadow
.node {
display: grid;
grid-template-columns: 24px min-content;
grid-template-rows:
min-content
1fr;
margin-top: 12px;
.expand-toggle {
img {
width: 16px;
height: 16px;
}
}
.name {
white-space: nowrap;
cursor: pointer;
user-select: none;
&:hover {
color: @color1;
}
&.selected {
color: @color1;
font-weight: bold;
}
}
.children {
padding-left: 24px;
margin-left: 8px;
border-left: 1px solid #555;
grid-column: 1 / -1;
&.collapsed {
display: none;
}
}
}
}

View File

@ -21,7 +21,11 @@
"preact/signals": "/js/{{ .VERSION }}/lib/signals/signals.mjs", "preact/signals": "/js/{{ .VERSION }}/lib/signals/signals.mjs",
"htm": "/js/{{ .VERSION }}/lib/htm/htm.mjs", "htm": "/js/{{ .VERSION }}/lib/htm/htm.mjs",
"api": "/js/{{ .VERSION }}/api.mjs" "api": "/js/{{ .VERSION }}/api.mjs",
"key": "/js/{{ .VERSION }}/key.mjs",
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
"node": "/js/{{ .VERSION }}/node.mjs"
{{/* {{/*
"session": "/js/{{ .VERSION }}/session.mjs", "session": "/js/{{ .VERSION }}/session.mjs",
"node": "/js/{{ .VERSION }}/node.mjs", "node": "/js/{{ .VERSION }}/node.mjs",

View File

@ -1,4 +1,5 @@
{{ define "page" }} {{ define "page" }}
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/notes2.css">
<script type="module"> <script type="module">
import { h, Component, render, createRef } from 'preact' import { h, Component, render, createRef } from 'preact'