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
|
||||
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
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
|
||||
|
||||
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
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 {
|
||||
box-sizing: border-box;
|
||||
background: #efede8;
|
||||
font-size: 14pt;
|
||||
}
|
||||
*,
|
||||
*: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
|
||||
#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
|
||||
|
@ -1,5 +1,9 @@
|
||||
@import "theme.less";
|
||||
|
||||
html {
|
||||
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)
|
||||
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>
|
||||
|
||||
|
@ -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