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) { // {{{
_, err = mngr.db.Exec(`
INSERT INTO public.user(username, password, name, totp)
INSERT INTO public.user(username, password, name)
VALUES(
$1,
public.password_hash(
@ -199,8 +199,7 @@ func (mngr *Manager) CreateUser(username, password, name string) (alreadyExists
/* password */
$2::bytea
),
$3,
''
$3
)
`,
username,

22
main.go
View File

@ -163,6 +163,10 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
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) { // {{{
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) { // {{{
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 {
OK bool
Foo string
User User
}{true, "FOO", user})
Nodes []Node
}{true, nodes})
Log.Debug("tree", "nodes", nodes)
w.Write(j)
} // }}}
@ -261,3 +271,7 @@ func changePassword(username string) { // {{{
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 (
id serial NOT NULL,
"name" varchar NOT NULL,
"username" varchar NOT NULL,
"password" char(96) NOT NULL,
totp varchar NOT NULL,
last_login timestamp with time zone NOT NULL DEFAULT '1970-01-01 00:00:00',
CONSTRAINT user_pk PRIMARY KEY (id),
CONSTRAINT user_un UNIQUE (username)
CREATE TABLE public."user" (
id SERIAL NOT NULL,
username VARCHAR NOT NULL,
name VARCHAR NOT NULL,
"password" VARCHAR NOT NULL,
last_login TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT newtable_pk PRIMARY KEY (id)
);
CREATE TABLE public.session (
id serial NOT NULL,
user_id int4 NULL,
"uuid" char(36) NOT NULL,
created timestamp with time zone NOT NULL DEFAULT NOW(),
last_used timestamp with time zone NOT NULL DEFAULT NOW(),
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 TABLE public."session" (
uuid UUID NOT NULL,
user_id INT4 NULL,
created TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT session_pk PRIMARY KEY (uuid),
CONSTRAINT user_session_fk FOREIGN KEY (user_id) REFERENCES public."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;
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);
ALTER TABLE node ADD COLUMN updated TIMESTAMP NOT NULL DEFAULT NOW();
CREATE OR REPLACE FUNCTION node_update_timestamp()
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 { signal } from 'preact/signals'
import htm from 'htm'
import { API } from 'api'
import { Node } from 'node'
const html = htm.bind(h)
export class Notes2 {
constructor() {//{{{
this.startNode = null
this.setStartNode()
}//}}}
render() {//{{{
return html`
<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() {
const req = {}
API.query('POST', '/tree/get', req)
API.query('POST', '/node/tree', req)
.then(response => {
console.log(response)
})
.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",
"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",
"node": "/js/{{ .VERSION }}/node.mjs",

View File

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