diff --git a/authentication/pkg.go b/authentication/pkg.go
index 915fd5b..2806114 100644
--- a/authentication/pkg.go
+++ b/authentication/pkg.go
@@ -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
diff --git a/file.go b/file.go
new file mode 100644
index 0000000..c5bca8c
--- /dev/null
+++ b/file.go
@@ -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
+}
diff --git a/main.go b/main.go
index 8ee157e..4fc380e 100644
--- a/main.go
+++ b/main.go
@@ -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)
diff --git a/node.go b/node.go
new file mode 100644
index 0000000..e1e815c
--- /dev/null
+++ b/node.go
@@ -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
+} // }}}
diff --git a/page.go b/page.go
new file mode 100644
index 0000000..ee5c255
--- /dev/null
+++ b/page.go
@@ -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
+}
diff --git a/sql/00002.sql b/sql/00002.sql
new file mode 100644
index 0000000..cf148a7
--- /dev/null
+++ b/sql/00002.sql
@@ -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()
diff --git a/static/css/login.css b/static/css/login.css
new file mode 100644
index 0000000..88a9140
--- /dev/null
+++ b/static/css/login.css
@@ -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;
+}
diff --git a/static/css/main.css b/static/css/main.css
index 1888e5b..05539e5 100644
--- a/static/css/main.css
+++ b/static/css/main.css
@@ -1,5 +1,7 @@
html {
box-sizing: border-box;
+ background: #efede8;
+ font-size: 14pt;
}
*,
*:before,
diff --git a/static/css/theme.css b/static/css/theme.css
new file mode 100644
index 0000000..e69de29
diff --git a/static/images/design.svg b/static/images/design.svg
new file mode 100644
index 0000000..4fe5ef9
--- /dev/null
+++ b/static/images/design.svg
@@ -0,0 +1,377 @@
+
+
+
+
diff --git a/static/images/logo-orig.svg b/static/images/logo-orig.svg
index 94d53ac..c014403 100644
--- a/static/images/logo-orig.svg
+++ b/static/images/logo-orig.svg
@@ -7,7 +7,7 @@
viewBox="0 0 210 297"
version="1.1"
id="svg1"
- inkscape:version="1.3.2 (091e20e, 2023-11-25)"
+ inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
sodipodi:docname="logo-orig.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
@@ -15,24 +15,26 @@
xmlns:svg="http://www.w3.org/2000/svg">