wip
This commit is contained in:
parent
515c748e14
commit
bd4a475923
@ -51,6 +51,25 @@ func NewManager(db *sqlx.DB, log *slog.Logger, secret string, expireDays int) (m
|
|||||||
mngr.ExpireDays = expireDays
|
mngr.ExpireDays = expireDays
|
||||||
return
|
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) { // {{{
|
func (mngr *Manager) GenerateToken(data map[string]any) (string, error) { // {{{
|
||||||
// Create a new token object, specifying signing method and the claims
|
// 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.
|
// Sign and get the complete encoded token as a string using the secret.
|
||||||
return token.SignedString(mngr.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
|
// 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
|
// 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
|
// 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
|
return mngr.secret, nil
|
||||||
})
|
})
|
||||||
if err != 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 {
|
if claims, ok := token.Claims.(jwt.MapClaims); ok {
|
||||||
|
err = validateTokenTimestamps(claims)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return claims, nil
|
return claims, nil
|
||||||
} else {
|
} else {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -97,6 +121,7 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
body, _ := io.ReadAll(r.Body)
|
body, _ := io.ReadAll(r.Body)
|
||||||
err := json.Unmarshal(body, &request)
|
err := json.Unmarshal(body, &request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
mngr.log.Debug("authentication", "error", err)
|
||||||
httpError(w, err)
|
httpError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -104,11 +129,13 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
// Verify username and password against the db user table.
|
// Verify username and password against the db user table.
|
||||||
authenticated, user, err := mngr.Authenticate(request.Username, request.Password)
|
authenticated, user, err := mngr.Authenticate(request.Username, request.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
mngr.log.Error("authentication", "error", err)
|
||||||
httpError(w, err)
|
httpError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !authenticated {
|
if !authenticated {
|
||||||
|
mngr.log.Info("authentication", "username", request.Username, "status", "failed")
|
||||||
httpError(w, errors.New("Authentication failed"))
|
httpError(w, errors.New("Authentication failed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -121,10 +148,12 @@ func (mngr *Manager) AuthenticationHandler(w http.ResponseWriter, r *http.Reques
|
|||||||
data["name"] = user.Name
|
data["name"] = user.Name
|
||||||
token, err = mngr.GenerateToken(data)
|
token, err = mngr.GenerateToken(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
mngr.log.Error("authentication", "error", err)
|
||||||
httpError(w, err)
|
httpError(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mngr.log.Info("authentication", "username", request.Username, "status", "accepted")
|
||||||
j, _ := json.Marshal(struct {
|
j, _ := json.Marshal(struct {
|
||||||
OK bool
|
OK bool
|
||||||
User User
|
User User
|
||||||
|
17
file.go
Normal file
17
file.go
Normal 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
97
main.go
@ -1,8 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
// External
|
|
||||||
|
|
||||||
// Internal
|
// Internal
|
||||||
"notes2/authentication"
|
"notes2/authentication"
|
||||||
"notes2/html_template"
|
"notes2/html_template"
|
||||||
@ -10,17 +8,21 @@ import (
|
|||||||
|
|
||||||
// Standard
|
// Standard
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "v1"
|
const VERSION = "v1"
|
||||||
|
const CONTEXT_USER = 1
|
||||||
|
|
||||||
var (
|
var (
|
||||||
FlagDev bool
|
FlagDev bool
|
||||||
@ -31,6 +33,7 @@ var (
|
|||||||
config Config
|
config Config
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
AuthManager authentication.Manager
|
AuthManager authentication.Manager
|
||||||
|
RxpBearerToken *regexp.Regexp
|
||||||
|
|
||||||
//go:embed views
|
//go:embed views
|
||||||
ViewFS embed.FS
|
ViewFS embed.FS
|
||||||
@ -55,6 +58,8 @@ func init() { // {{{
|
|||||||
flag.StringVar(&FlagCreateUser, "create-user", "", "Username for creating a new user")
|
flag.StringVar(&FlagCreateUser, "create-user", "", "Username for creating a new user")
|
||||||
flag.StringVar(&FlagChangePassword, "change-password", "", "Change the password for the given username")
|
flag.StringVar(&FlagChangePassword, "change-password", "", "Change the password for the given username")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
RxpBearerToken = regexp.MustCompile("(?i)^\\s*Bearer\\s+(.*?)\\s*$")
|
||||||
} // }}}
|
} // }}}
|
||||||
func initLog() { // {{{
|
func initLog() { // {{{
|
||||||
opts := slog.HandlerOptions{}
|
opts := slog.HandlerOptions{}
|
||||||
@ -91,7 +96,6 @@ func main() { // {{{
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// The webengine takes layouts, pages and components and renders them into HTML.
|
// The webengine takes layouts, pages and components and renders them into HTML.
|
||||||
Webengine, err = HTMLTemplate.NewEngine(ViewFS, StaticFS, FlagDev)
|
Webengine, err = HTMLTemplate.NewEngine(ViewFS, StaticFS, FlagDev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -99,23 +103,67 @@ func main() { // {{{
|
|||||||
}
|
}
|
||||||
|
|
||||||
http.HandleFunc("/", rootHandler)
|
http.HandleFunc("/", rootHandler)
|
||||||
|
http.HandleFunc("/notes2", pageNotes2)
|
||||||
http.HandleFunc("/login", pageLogin)
|
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)
|
http.HandleFunc("/service_worker.js", pageServiceWorker)
|
||||||
|
|
||||||
listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port)
|
listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port)
|
||||||
|
Log.Info("webserver", "listen_address", listen)
|
||||||
http.ListenAndServe(listen, nil)
|
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) { // {{{
|
func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
// All URLs not specifically handled are routed to this function.
|
// All URLs not specifically handled are routed to this function.
|
||||||
// Everything going here should be a static resource.
|
// Everything going here should be a static resource.
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path == "/" {
|
||||||
Webengine.StaticResource(w, r)
|
http.Redirect(w, r, "/notes2", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pageIndex(w, r)
|
Webengine.StaticResource(w, r)
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
@ -137,20 +185,6 @@ func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{
|
|||||||
return
|
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) { // {{{
|
func pageLogin(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
page := HTMLTemplate.SimplePage{
|
page := HTMLTemplate.SimplePage{
|
||||||
Layout: "main",
|
Layout: "main",
|
||||||
@ -164,14 +198,27 @@ func pageLogin(w http.ResponseWriter, r *http.Request) { // {{{
|
|||||||
return
|
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) { // {{{
|
err := Webengine.Render(page, w, r)
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
if err != nil {
|
||||||
fmt.Printf("Filtered ")
|
w.Write([]byte(err.Error()))
|
||||||
fn(w, r)
|
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) { // {{{
|
func createNewUser(username string) { // {{{
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
|
||||||
|
111
node.go
Normal file
111
node.go
Normal 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
34
page.go
Normal 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
46
sql/00002.sql
Normal 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
37
static/css/login.css
Normal 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;
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
html {
|
html {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
background: #efede8;
|
||||||
|
font-size: 14pt;
|
||||||
}
|
}
|
||||||
*,
|
*,
|
||||||
*:before,
|
*:before,
|
||||||
|
0
static/css/theme.css
Normal file
0
static/css/theme.css
Normal file
377
static/images/design.svg
Normal 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
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
67
static/js/api.mjs
Normal 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 = '/'
|
||||||
|
}//}}}
|
||||||
|
}
|
@ -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
44
static/less/login.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -6,14 +6,10 @@ do
|
|||||||
inotifywait -q -e MODIFY *less
|
inotifywait -q -e MODIFY *less
|
||||||
#sleep 0.5
|
#sleep 0.5
|
||||||
clear
|
clear
|
||||||
|
if make -j12; then
|
||||||
for THEME in $(ls theme-*.less | sed -e 's/^theme-\(.*\)\.less$/\1/'); do
|
echo -e "\n\e[32;1mOK!\e[0m"
|
||||||
export THEME=$THEME
|
curl -s http://notes.lan:1371/css_updated
|
||||||
make -j12
|
sleep 1
|
||||||
#curl -s http://notes.lan:1371/_ws/css_update
|
clear
|
||||||
done
|
fi
|
||||||
echo -e "\n\e[32;1mOK!\e[0m"
|
|
||||||
sleep 1
|
|
||||||
clear
|
|
||||||
|
|
||||||
done
|
done
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
|
@import "theme.less";
|
||||||
|
|
||||||
html {
|
html {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
background: @color2;
|
||||||
|
font-size: 14pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
3
static/less/theme.less
Normal file
3
static/less/theme.less
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@color1: #fe5f55;
|
||||||
|
@color2: #efede8;
|
||||||
|
@color3: #666;
|
24
user.go
Normal file
24
user.go
Normal 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
|
||||||
|
}
|
@ -10,11 +10,9 @@
|
|||||||
if (navigator.serviceWorker)
|
if (navigator.serviceWorker)
|
||||||
navigator.serviceWorker.register('/service_worker.js')
|
navigator.serviceWorker.register('/service_worker.js')
|
||||||
</script>
|
</script>
|
||||||
<!--
|
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
/*
|
|
||||||
"preact": "/js/{{ .VERSION }}/lib/preact/preact.mjs",
|
"preact": "/js/{{ .VERSION }}/lib/preact/preact.mjs",
|
||||||
"preact/hooks": "/js/{{ .VERSION }}/lib/preact/hooks.mjs",
|
"preact/hooks": "/js/{{ .VERSION }}/lib/preact/hooks.mjs",
|
||||||
"preact/debug": "/js/{{ .VERSION }}/lib/preact/debug.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-core": "/js/{{ .VERSION }}/lib/signals/signals-core.mjs",
|
||||||
"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"
|
||||||
|
{{/*
|
||||||
"session": "/js/{{ .VERSION }}/session.mjs",
|
"session": "/js/{{ .VERSION }}/session.mjs",
|
||||||
"node": "/js/{{ .VERSION }}/node.mjs",
|
"node": "/js/{{ .VERSION }}/node.mjs",
|
||||||
"node_store": "/js/{{ .VERSION }}/node_store.mjs",
|
"node_store": "/js/{{ .VERSION }}/node_store.mjs",
|
||||||
@ -29,14 +30,13 @@
|
|||||||
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
|
"crypto": "/js/{{ .VERSION }}/crypto.mjs",
|
||||||
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
|
"checklist": "/js/{{ .VERSION }}/checklist.mjs",
|
||||||
"ws": "/_js/{{ .VERSION }}/websocket.mjs"
|
"ws": "/_js/{{ .VERSION }}/websocket.mjs"
|
||||||
*/
|
*/}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/sjcl.js"></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/node_modules/marked/marked.min.js"></script>
|
||||||
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script>
|
<script type="text/javascript" src="/js/{{ .VERSION }}/lib/fullcalendar.min.js"></script>
|
||||||
-->
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
{{ define "page" }}
|
|
||||||
<script type="module" src="/js/{{ .VERSION }}/app.mjs"></script>
|
|
||||||
{{ end }}
|
|
47
views/pages/login.gotmpl
Normal file
47
views/pages/login.gotmpl
Normal 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
22
views/pages/notes2.gotmpl
Normal 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 }}
|
Loading…
Reference in New Issue
Block a user