This commit is contained in:
Magnus Åhall 2024-11-28 18:11:14 +01:00
parent 515c748e14
commit bd4a475923
23 changed files with 1217 additions and 192 deletions

View File

@ -51,6 +51,25 @@ func NewManager(db *sqlx.DB, log *slog.Logger, secret string, expireDays int) (m
mngr.ExpireDays = expireDays
return
} // }}}
func validateTokenTimestamps(claims jwt.MapClaims) error { // {{{
now := time.Now()
if issuedAt, ok := claims["iat"].(float64); ok {
if now.Unix() < int64(issuedAt) {
return errors.New("Token is not valid yet")
}
} else {
return errors.New("Token is missing iat")
}
if expires, ok := claims["exp"].(float64); ok {
if now.Unix() > int64(expires) {
return errors.New("Token has expired")
}
}
return nil
} // }}}
func (mngr *Manager) GenerateToken(data map[string]any) (string, error) { // {{{
// Create a new token object, specifying signing method and the claims
@ -64,7 +83,7 @@ func (mngr *Manager) GenerateToken(data map[string]any) (string, error) { // {{{
// Sign and get the complete encoded token as a string using the secret.
return token.SignedString(mngr.secret)
} // }}}
func (mngr *Manager) VerifyToken(tokenString string) (jwt.Claims, error) { // {{{
func (mngr *Manager) VerifyToken(tokenString string) (jwt.MapClaims, error) { // {{{
// Parse takes the token string and a function for looking up the key. The latter is especially
// useful if you use multiple keys for your application. The standard is to use 'kid' in the
// head of the token to identify which key to use, but the parsed token (head and claims) is provided
@ -79,10 +98,15 @@ func (mngr *Manager) VerifyToken(tokenString string) (jwt.Claims, error) { // {{
return mngr.secret, nil
})
if err != nil {
mngr.log.Error("jwt", "error", err)
mngr.log.Error("authentication", "error", err)
return nil, err
}
if claims, ok := token.Claims.(jwt.MapClaims); ok {
err = validateTokenTimestamps(claims)
if err != nil {
return nil, err
}
return claims, nil
} else {
return nil, err
@ -97,6 +121,7 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques
body, _ := io.ReadAll(r.Body)
err := json.Unmarshal(body, &request)
if err != nil {
mngr.log.Debug("authentication", "error", err)
httpError(w, err)
return
}
@ -104,11 +129,13 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques
// Verify username and password against the db user table.
authenticated, user, err := mngr.Authenticate(request.Username, request.Password)
if err != nil {
mngr.log.Error("authentication", "error", err)
httpError(w, err)
return
}
if !authenticated {
mngr.log.Info("authentication", "username", request.Username, "status", "failed")
httpError(w, errors.New("Authentication failed"))
return
}
@ -121,10 +148,12 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques
data["name"] = user.Name
token, err = mngr.GenerateToken(data)
if err != nil {
mngr.log.Error("authentication", "error", err)
httpError(w, err)
return
}
mngr.log.Info("authentication", "username", request.Username, "status", "accepted")
j, _ := json.Marshal(struct {
OK bool
User User

17
file.go Normal file
View File

@ -0,0 +1,17 @@
package main
import (
// Standard
"time"
)
type File struct {
ID int
UserID int `db:"user_id"`
NodeID int `db:"node_id"`
Filename string
Size int64
MIME string
MD5 string
Uploaded time.Time
}

97
main.go
View File

@ -1,8 +1,6 @@
package main
import (
// External
// Internal
"notes2/authentication"
"notes2/html_template"
@ -10,17 +8,21 @@ import (
// Standard
"bufio"
"context"
"embed"
"encoding/json"
"flag"
"fmt"
"log/slog"
"net/http"
"path"
"regexp"
"strings"
"text/template"
)
const VERSION = "v1"
const CONTEXT_USER = 1
var (
FlagDev bool
@ -31,6 +33,7 @@ var (
config Config
Log *slog.Logger
AuthManager authentication.Manager
RxpBearerToken *regexp.Regexp
//go:embed views
ViewFS embed.FS
@ -55,6 +58,8 @@ func init() { // {{{
flag.StringVar(&FlagCreateUser, "create-user", "", "Username for creating a new user")
flag.StringVar(&FlagChangePassword, "change-password", "", "Change the password for the given username")
flag.Parse()
RxpBearerToken = regexp.MustCompile("(?i)^\\s*Bearer\\s+(.*?)\\s*$")
} // }}}
func initLog() { // {{{
opts := slog.HandlerOptions{}
@ -91,7 +96,6 @@ func main() { // {{{
return
}
// The webengine takes layouts, pages and components and renders them into HTML.
Webengine, err = HTMLTemplate.NewEngine(ViewFS, StaticFS, FlagDev)
if err != nil {
@ -99,23 +103,67 @@ func main() { // {{{
}
http.HandleFunc("/", rootHandler)
http.HandleFunc("/notes2", pageNotes2)
http.HandleFunc("/login", pageLogin)
http.HandleFunc("/authenticate", AuthManager.AuthenticationHandler)
http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler)
http.HandleFunc("/node/tree", authenticated(actionNodeTree))
http.HandleFunc("/service_worker.js", pageServiceWorker)
listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port)
Log.Info("webserver", "listen_address", listen)
http.ListenAndServe(listen, nil)
} // }}}
func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { // {{{
return func(w http.ResponseWriter, r *http.Request) {
failed := func(err error) {
j, _ := json.Marshal(struct {
OK bool
Error string
AuthFailed bool
}{false, err.Error(), true})
w.Write(j)
}
// The Bearer token is extracted.
authHeader := r.Header.Get("Authorization")
authParts := RxpBearerToken.FindStringSubmatch(authHeader)
if len(authParts) != 2 {
failed(fmt.Errorf("Authorization missing or invalid"))
return
}
token := authParts[1]
// Token signature is verified with the application secret key.
claims, err := AuthManager.VerifyToken(token)
if err != nil {
failed(err)
return
}
// User object is added to the context for the next handler.
user := NewUser(claims)
r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user))
Log.Info("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username)
fn(w, r)
}
} // }}}
func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
// All URLs not specifically handled are routed to this function.
// Everything going here should be a static resource.
if r.URL.Path != "/" {
Webengine.StaticResource(w, r)
if r.URL.Path == "/" {
http.Redirect(w, r, "/notes2", http.StatusSeeOther)
return
}
pageIndex(w, r)
Webengine.StaticResource(w, r)
} // }}}
func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{
w.Header().Add("Content-Type", "text/javascript; charset=utf-8")
@ -137,20 +185,6 @@ func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{
return
}
} // }}}
func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{
page := HTMLTemplate.SimplePage{
Layout: "main",
Page: "index",
Version: VERSION,
}
err := Webengine.Render(page, w, r)
if err != nil {
w.Write([]byte(err.Error()))
return
}
} // }}}
func pageLogin(w http.ResponseWriter, r *http.Request) { // {{{
page := HTMLTemplate.SimplePage{
Layout: "main",
@ -164,14 +198,27 @@ func pageLogin(w http.ResponseWriter, r *http.Request) { // {{{
return
}
} // }}}
func pageNotes2(w http.ResponseWriter, r *http.Request) { // {{{
page := NewPage("notes2")
func sessionFilter(fn func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { // {{{
return func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("Filtered ")
fn(w, r)
err := Webengine.Render(page, w, r)
if err != nil {
w.Write([]byte(err.Error()))
return
}
} // }}}
func actionNodeTree(w http.ResponseWriter, r *http.Request) { // {{{
user, _ := r.Context().Value(CONTEXT_USER).(User)
j, _ := json.Marshal(struct {
OK bool
Foo string
User User
}{true, "FOO", user})
w.Write(j)
} // }}}
func createNewUser(username string) { // {{{
reader := bufio.NewReader(os.Stdin)

111
node.go Normal file
View File

@ -0,0 +1,111 @@
package main
import (
// External
"github.com/jmoiron/sqlx"
// Standard
"time"
)
type ChecklistItem struct {
ID int
GroupID int `db:"checklist_group_id"`
Order int
Label string
Checked bool
}
type ChecklistGroup struct {
ID int
NodeID int `db:"node_id"`
Order int
Label string
Items []ChecklistItem
}
type Node struct {
ID int
UserID int `db:"user_id"`
ParentID int `db:"parent_id"`
CryptoKeyID int `db:"crypto_key_id"`
Name string
Content string
Updated time.Time
Children []Node
Crumbs []Node
Files []File
Complete bool
Level int
ChecklistGroups []ChecklistGroup
ContentEncrypted string `db:"content_encrypted" json:"-"`
Markdown bool
}
func NodeTree(userID, startNodeID int) (nodes []Node, err error) { // {{{
var rows *sqlx.Rows
rows, err = db.Queryx(`
WITH RECURSIVE nodetree AS (
SELECT
*,
array[name::text] AS path,
0 AS level
FROM node
WHERE
user_id = $1 AND
CASE $2::int
WHEN 0 THEN parent_id IS NULL
ELSE parent_id = $2
END
UNION ALL
SELECT
n.*,
path||n.name::text AS path,
nt.level + 1 AS level
FROM node n
INNER JOIN nodetree nt ON n.parent_id = nt.id
)
SELECT
id,
user_id,
COALESCE(parent_id, 0) AS parent_id,
name,
updated,
level
FROM nodetree
ORDER BY
path ASC
`,
userID,
startNodeID,
)
if err != nil {
return
}
defer rows.Close()
type resultRow struct {
Node
Level int
}
nodes = []Node{}
for rows.Next() {
node := Node{}
node.Complete = false
node.Crumbs = []Node{}
node.Children = []Node{}
node.Files = []File{}
if err = rows.StructScan(&node); err != nil {
return
}
nodes = append(nodes, node)
}
return
} // }}}

34
page.go Normal file
View File

@ -0,0 +1,34 @@
package main
import (
// Internal
"notes2/html_template"
)
type Page struct {
HTMLTemplate.SimplePage
Data map[string]any
}
func NewPage(page string) (p Page) {
p.Page = page
p.Data = make(map[string]any)
return
}
func (p Page) GetVersion() string {
return VERSION
}
func (p Page) GetLayout() string {
if p.Layout == "" {
return "main"
}
return p.Layout
}
func (p Page) GetData() any {
p.Data["_dev"] = FlagDev
p.Data["GRIS"] = "foo"
return p.Data
}

46
sql/00002.sql Normal file
View File

@ -0,0 +1,46 @@
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);
CREATE OR REPLACE FUNCTION node_update_timestamp()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
IF NEW.updated = OLD.updated THEN
UPDATE node SET updated = NOW() WHERE id=NEW.id;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE TRIGGER node_update AFTER UPDATE ON node
FOR EACH ROW
EXECUTE PROCEDURE node_update_timestamp()

37
static/css/login.css Normal file
View File

@ -0,0 +1,37 @@
#app {
display: grid;
justify-items: center;
margin-top: 128px;
}
#logo {
margin-bottom: 48px;
}
#box {
display: grid;
grid-gap: 16px 0;
justify-items: center;
width: 300px;
padding: 48px 0px;
background-color: #fff;
box-shadow: 0px 20px 52px -33px rgba(0, 0, 0, 0.75);
border-left: 8px solid #666;
}
#box input {
padding: 4px 8px;
font-size: 1em;
width: calc(100% - 64px);
border: 1px solid #aaa;
border-radius: 4px;
}
#box button {
padding: 6px 16px;
font-size: 1em;
border-radius: 4px;
border: none;
background-color: #fe5f55;
color: #fff;
}
#box #error {
color: #c33;
margin-top: 16px;
}

View File

@ -1,5 +1,7 @@
html {
box-sizing: border-box;
background: #efede8;
font-size: 14pt;
}
*,
*:before,

0
static/css/theme.css Normal file
View File

377
static/images/design.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 123 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 20 KiB

75
static/images/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

67
static/js/api.mjs Normal file
View File

@ -0,0 +1,67 @@
export class API {
// query resolves into the JSON data produced by the application, or an exception with 'type' and 'error' properties.
static async query(method, path, request) {
return new Promise((resolve, reject) => {
const body = JSON.stringify(request)
const headers = {}
// Authentication is done with a bearer token.
// Here provided to the backend if set.
const token = localStorage.getItem('token')
if (token) {
headers.Authorization = `Bearer ${token}`
}
fetch(path, { method, headers, body })
.then(response => {
// An HTTP communication level error occured.
if (!response.ok || response.status != 200)
return reject({
type: 'http',
error: response,
})
return response.json()
})
.then(json => {
// Application level response are handled here.
if (!json.OK)
return reject({
type: 'application',
error: json.Error,
application: json,
})
resolve(json)
})
.catch(err =>
// Catch any other errors from fetch.
reject({
type: 'http',
error: err,
}))
})
}
static hasAuthenticationToken() {//{{{
const token = localStorage.getItem('token')
return token !== null && token !== ''
}//}}}
static authenticate(username, password) {//{{{
return new Promise((resolve, reject) => {
const req = { username, password }
API.query('POST', '/user/authenticate', req)
.then(response => {
localStorage.setItem('token', response.Token)
localStorage.setItem('user', JSON.stringify(response.User))
resolve(response.User)
})
.catch(e => {
console.log(e.type, e.error)
reject(e.error)
})
})
}//}}}
static logout() {//{{{
localStorage.removeItem('token')
location.href = '/'
}//}}}
}

View File

@ -1 +1,23 @@
console.log('app.mjs')
import { h, Component, createRef } from 'preact'
import htm from 'htm'
import { API } from 'api'
const html = htm.bind(h)
export class Notes2 {
constructor() {//{{{
}//}}}
render() {//{{{
return html`
<button onclick=${()=>API.logout()}>Log out</button>
`
}//}}}
treeGet() {
const req = {}
API.query('POST', '/tree/get', req)
.then(response => {
console.log(response)
})
.catch(e => console.log(e.type, e.error))
}
}

44
static/less/login.less Normal file
View File

@ -0,0 +1,44 @@
@import "theme.less";
#app {
display: grid;
justify-items: center;
margin-top: 128px;
}
#logo {
margin-bottom: 48px;
}
#box {
display: grid;
grid-gap: 16px 0;
justify-items: center;
width: 300px;
padding: 48px 0px;
background-color: #fff;
box-shadow: 0px 20px 52px -33px rgba(0,0,0,0.75);
border-left: 8px solid @color3;
input {
padding: 4px 8px;
font-size: 1em;
width: calc(100% - 64px);
border: 1px solid #aaa;
border-radius: 4px;
}
button {
padding: 6px 16px;
font-size: 1em;
border-radius: 4px;
border: none;
background-color: @color1;
color: #fff;
}
#error {
color: #c33;
margin-top: 16px;
}
}

View File

@ -6,14 +6,10 @@ do
inotifywait -q -e MODIFY *less
#sleep 0.5
clear
for THEME in $(ls theme-*.less | sed -e 's/^theme-\(.*\)\.less$/\1/'); do
export THEME=$THEME
make -j12
#curl -s http://notes.lan:1371/_ws/css_update
done
echo -e "\n\e[32;1mOK!\e[0m"
sleep 1
clear
if make -j12; then
echo -e "\n\e[32;1mOK!\e[0m"
curl -s http://notes.lan:1371/css_updated
sleep 1
clear
fi
done

View File

@ -1,5 +1,9 @@
@import "theme.less";
html {
box-sizing: border-box;
background: @color2;
font-size: 14pt;
}
*,

3
static/less/theme.less Normal file
View File

@ -0,0 +1,3 @@
@color1: #fe5f55;
@color2: #efede8;
@color3: #666;

24
user.go Normal file
View File

@ -0,0 +1,24 @@
package main
import (
// External
"github.com/golang-jwt/jwt/v5"
)
type User struct {
ID int
Username string
Password string
Name string
}
func NewUser(claims jwt.MapClaims) (u User) {
uid, _ := claims["uid"].(float64)
name, _ := claims["name"].(string)
username, _ := claims["login"].(string)
u.ID = int(uid)
u.Username = username
u.Name = name
return
}

View File

@ -10,11 +10,9 @@
if (navigator.serviceWorker)
navigator.serviceWorker.register('/service_worker.js')
</script>
<!--
<script type="importmap">
{
"imports": {
/*
"preact": "/js/{{ .VERSION }}/lib/preact/preact.mjs",
"preact/hooks": "/js/{{ .VERSION }}/lib/preact/hooks.mjs",
"preact/debug": "/js/{{ .VERSION }}/lib/preact/debug.mjs",
@ -22,6 +20,9 @@
"@preact/signals-core": "/js/{{ .VERSION }}/lib/signals/signals-core.mjs",
"preact/signals": "/js/{{ .VERSION }}/lib/signals/signals.mjs",
"htm": "/js/{{ .VERSION }}/lib/htm/htm.mjs",
"api": "/js/{{ .VERSION }}/api.mjs"
{{/*
"session": "/js/{{ .VERSION }}/session.mjs",
"node": "/js/{{ .VERSION }}/node.mjs",
"node_store": "/js/{{ .VERSION }}/node_store.mjs",
@ -29,14 +30,13 @@
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
"ws": "/_js/{{ .VERSION }}/websocket.mjs"
*/
*/}}
}
}
</script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/node_modules/marked/marked.min.js"></script>
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script>
-->
</head>
<body>

View File

@ -1,3 +0,0 @@
{{ define "page" }}
<script type="module" src="/js/{{ .VERSION }}/app.mjs"></script>
{{ end }}

47
views/pages/login.gotmpl Normal file
View File

@ -0,0 +1,47 @@
{{ define "page" }}
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/login.css">
<div id="box">
<img id="logo" src="/images/v1/logo.svg">
<input type="text" id="username" placeholder="Username">
<input type="password" id="password" placeholder="Password">
<button onclick=window._login.authenticate()>Log in</button>
<div id="error"></div>
</div>
<script type="module">
import { API } from 'api';
class Login {
constructor() {
this.errorDiv = document.getElementById('error')
document.getElementById('username').focus()
const username = document.getElementById('username')
const password = document.getElementById('password')
username.addEventListener('keydown', event=>this.keyHandler(event))
password.addEventListener('keydown', event=>this.keyHandler(event))
}
authenticate() {
this.errorDiv.innerText = ''
const username = document.getElementById('username').value
const password = document.getElementById('password').value
API.authenticate(username, password)
.then(ans=>{
location.href = '/notes2'
})
.catch(e=>{
setTimeout(()=>this.errorDiv.innerText = e, 75)
})
}
keyHandler(event) {
if (event.key == 'Enter') {
this.authenticate()
}
}
}
window._login = new Login()
</script>
{{ end }}

22
views/pages/notes2.gotmpl Normal file
View File

@ -0,0 +1,22 @@
{{ define "page" }}
<script type="module">
import { h, Component, render, createRef } from 'preact'
import htm from 'htm'
{{ if .Data._dev -}}
import 'preact/debug'
import 'preact/devtools'
{{- end }}
import { Notes2 } from "/js/{{ .VERSION }}/app.mjs"
import { API } from 'api'
if (!API.hasAuthenticationToken()) {
location.href = '/login'
} else {
const html = htm.bind(h)
window._notes2 = createRef()
render(html`<${Notes2} ref=${window._notes2} />`, document.getElementById('app'))
}
</script>
{{ end }}