diff --git a/config.go b/config.go index 026a06c..172e0c3 100644 --- a/config.go +++ b/config.go @@ -21,6 +21,10 @@ type Config struct { Username string Password string } + + Session struct { + DaysValid int + } } func ConfigRead(filename string) (config Config, err error) { diff --git a/main.go b/main.go index 8a75ee2..a3ff503 100644 --- a/main.go +++ b/main.go @@ -11,9 +11,10 @@ import ( "path" "regexp" "strings" + "time" ) -const VERSION = "v0.0.1"; +const VERSION = "v0.0.3"; const LISTEN_HOST = "0.0.0.0"; var ( @@ -33,6 +34,10 @@ func init() {// {{{ ) flag.Parse() + + if false { + time.Sleep(time.Second*1) + } }// }}} func main() {// {{{ var err error @@ -53,6 +58,7 @@ func main() {// {{{ http.HandleFunc("/session/create", sessionCreate) http.HandleFunc("/session/retrieve", sessionRetrieve) http.HandleFunc("/session/authenticate", sessionAuthenticate) + http.HandleFunc("/node/retrieve", nodeRetrieve) http.HandleFunc("/ws", websocketHandler) http.HandleFunc("/", staticHandler) @@ -66,66 +72,6 @@ func cssUpdateHandler(w http.ResponseWriter, r *http.Request) {// {{{ log.Println("[BROADCAST] CSS updated") connectionManager.Broadcast(struct{ Ok bool; ID string; Op string }{ Ok: true, Op: "css_reload" }) }// }}} -func sessionCreate(w http.ResponseWriter, r *http.Request) {// {{{ - session, err := NewSession() - if err != nil { - responseError(w, err) - return - } - responseData(w, map[string]interface{}{ - "OK": true, - "Session": session, - }) -}// }}} -func sessionRetrieve(w http.ResponseWriter, r *http.Request) {// {{{ - var err error - var uuid string - - if uuid, err = sessionUUID(r); err != nil { - responseError(w, err) - return - } - - session := Session{ UUID: uuid } - if err = session.Retrieve(); err != nil { - responseError(w, err) - return - } - - responseData(w, map[string]interface{}{ - "OK": true, - "Session": session, - }) -}// }}} -func sessionAuthenticate(w http.ResponseWriter, r *http.Request) {// {{{ - var err error - var uuid string - - if uuid, err = sessionUUID(r); err != nil { - responseError(w, err) - return - } - - session := Session{ - UUID: uuid, - } - - req := struct{ Username string; Password string }{} - if err = request(r, &req); err != nil { - responseError(w, err) - return - } - - if err = session.Authenticate(req.Username, req.Password); err != nil { - responseError(w, err) - return - } - - responseData(w, map[string]interface{}{ - "OK": true, - "Session": session, - }) -}// }}} func websocketHandler(w http.ResponseWriter, r *http.Request) {// {{{ var err error @@ -145,6 +91,7 @@ func staticHandler(w http.ResponseWriter, r *http.Request) {// {{{ // URLs with pattern /(css|images)/v1.0.0/foobar are stripped of the version. // To get rid of problems with cached content in browser on a new version release, // while also not disabling cache altogether. + log.Printf("static: %s", r.URL.Path) rxp := regexp.MustCompile("^/(css|images|js|fonts)/v[0-9]+\\.[0-9]+\\.[0-9]+/(.*)$") if comp := rxp.FindStringSubmatch(r.URL.Path); comp != nil { r.URL.Path = fmt.Sprintf("/%s/%s", comp[1], comp[2]) @@ -154,6 +101,7 @@ func staticHandler(w http.ResponseWriter, r *http.Request) {// {{{ // Everything else is run through the template system. // For now to get VERSION into files to fix caching. + log.Printf("template: %s", r.URL.Path) tmpl, err := newTemplate(r.URL.Path) if err != nil { if os.IsNotExist(err) { @@ -168,6 +116,93 @@ func staticHandler(w http.ResponseWriter, r *http.Request) {// {{{ } }// }}} +func sessionCreate(w http.ResponseWriter, r *http.Request) {// {{{ + log.Println("/session/create") + session, err := CreateSession() + if err != nil { + responseError(w, err) + return + } + responseData(w, map[string]interface{}{ + "OK": true, + "Session": session, + }) +}// }}} +func sessionRetrieve(w http.ResponseWriter, r *http.Request) {// {{{ + log.Println("/session/retrieve") + var err error + var found bool + var session Session + + if session, found, err = ValidateSession(r, false); err != nil { + responseError(w, err) + return + } + + responseData(w, map[string]interface{}{ + "OK": true, + "Valid": found, + "Session": session, + }) +}// }}} +func sessionAuthenticate(w http.ResponseWriter, r *http.Request) {// {{{ + log.Println("/session/authenticate") + var err error + var session Session + var authenticated bool + + // Validate session + if session, _, err = ValidateSession(r, true); err != nil { + responseError(w, err) + return + } + + req := struct{ Username string; Password string }{} + if err = parseRequest(r, &req); err != nil { + responseError(w, err) + return + } + + if authenticated, err = session.Authenticate(req.Username, req.Password); err != nil { + responseError(w, err) + return + } + + responseData(w, map[string]interface{}{ + "OK": true, + "Authenticated": authenticated, + "Session": session, + }) +}// }}} + +func nodeRetrieve(w http.ResponseWriter, r *http.Request) {// {{{ + log.Println("/node/retrieve") + var err error + var session Session + + if session, _, err = ValidateSession(r, true); err != nil { + responseError(w, err) + return + } + + req := struct { ID int }{} + if err = parseRequest(r, &req); err != nil { + responseError(w, err) + return + } + + node, err := session.Node(req.ID) + if err != nil { + responseError(w, err) + return + } + + responseData(w, map[string]interface{}{ + "OK": true, + "Node": node, + }) +}// }}} + func newTemplate(requestPath string) (tmpl *template.Template, err error) {// {{{ // Append index.html if needed for further reading of the file p := requestPath diff --git a/node.go b/node.go new file mode 100644 index 0000000..a0f095e --- /dev/null +++ b/node.go @@ -0,0 +1,183 @@ +package main + +import ( + // External + "github.com/jmoiron/sqlx" + + // Standard + +) + +type Node struct { + ID int + UserID int `db:"user_id"` + ParentID int `db:"parent_id"` + Name string + Content string + Children []Node + Crumbs []Node + Complete bool +} + +func (session Session) RootNode() (node Node, err error) {// {{{ + var rows *sqlx.Rows + rows, err = db.Queryx(` + SELECT + id, + user_id, + 0 AS parent_id, + name + FROM node + WHERE + user_id = $1 AND + parent_id IS NULL + `, + session.UserID, + ) + if err != nil { + return + } + defer rows.Close() + + node.Name = "Start" + node.UserID = session.UserID + node.Complete = true + node.Children = []Node{} + node.Crumbs = []Node{} + for rows.Next() { + row := Node{} + if err = rows.StructScan(&row); err != nil { + return + } + + node.Children = append(node.Children, Node{ + ID: row.ID, + UserID: row.UserID, + ParentID: row.ParentID, + Name: row.Name, + }) + } + + return +}// }}} +func (session Session) Node(nodeID int) (node Node, err error) {// {{{ + if nodeID == 0 { + return session.RootNode() + } + + var rows *sqlx.Rows + rows, err = db.Queryx(` + WITH RECURSIVE recurse AS ( + SELECT + id, + user_id, + COALESCE(parent_id, 0) AS parent_id, + name, + content, + 0 AS level + FROM node + WHERE + user_id = $1 AND + id = $2 + + UNION + + SELECT + n.id, + n.user_id, + n.parent_id, + n.name, + '' AS content, + r.level + 1 AS level + FROM node n + INNER JOIN recurse r ON n.parent_id = r.id AND r.level = 0 + WHERE + n.user_id = $1 + ) + + SELECT * FROM recurse ORDER BY level ASC + `, + session.UserID, + nodeID, + ) + if err != nil { + return + } + defer rows.Close() + + type resultRow struct { + Node + Level int + } + + for rows.Next() { + row := resultRow{} + if err = rows.StructScan(&row); err != nil { + return + } + + if row.Level == 0 { + node = Node{} + node.Children = []Node{} + node.ID = row.ID + node.UserID = row.UserID + node.ParentID = row.ParentID + node.Name = row.Name + node.Content = row.Content + node.Complete = true + } + + if row.Level == 1 { + node.Children = append(node.Children, Node{ + ID: row.ID, + UserID: row.UserID, + ParentID: row.ParentID, + Name: row.Name, + }) + } + } + + node.Crumbs, err = session.NodeCrumbs(node.ID) + + return +}// }}} +func (session Session) NodeCrumbs(nodeID int) (nodes []Node, err error) {// {{{ + var rows *sqlx.Rows + rows, err = db.Queryx(` + WITH RECURSIVE nodes AS ( + SELECT + id, + COALESCE(parent_id, 0) AS parent_id, + name + FROM node + WHERE + id = $1 + + UNION + + SELECT + n.id, + COALESCE(n.parent_id, 0) AS parent_id, + n.name + FROM node n + INNER JOIN nodes nr ON n.id = nr.parent_id + ) + SELECT * FROM nodes + `, nodeID) + if err != nil { + return + } + defer rows.Close() + + nodes = []Node{} + for rows.Next() { + node := Node{} + if err = rows.StructScan(&node); err != nil { + return + } + nodes = append(nodes, node) + } + return +}// }}} + +// vim: foldmethod=marker diff --git a/request_response.go b/request_response.go index 90e8ad0..c2c6ba5 100644 --- a/request_response.go +++ b/request_response.go @@ -3,7 +3,6 @@ package main import ( // Standard "encoding/json" - "errors" "io" "net/http" ) @@ -14,8 +13,8 @@ type Request struct { func responseError(w http.ResponseWriter, err error) { res := map[string]interface{}{ - "ok": false, - "error": err.Error(), + "OK": false, + "Error": err.Error(), } resJSON, _ := json.Marshal(res) @@ -29,17 +28,9 @@ func responseData(w http.ResponseWriter, data interface{}) { w.Write(resJSON) } -func request(r *http.Request, data interface{}) (err error) { +func parseRequest(r *http.Request, data interface{}) (err error) { body, _ := io.ReadAll(r.Body) defer r.Body.Close() err = json.Unmarshal(body, data) return } - -func sessionUUID(r *http.Request) (string, error) { - headers := r.Header["X-Session-Id"] - if len(headers) > 0 { - return headers[0], nil - } - return "", errors.New("Invalid session") -} diff --git a/session.go b/session.go index 3aa27e8..2457eb3 100644 --- a/session.go +++ b/session.go @@ -4,6 +4,8 @@ import ( // Standard "database/sql" "errors" + "fmt" + "net/http" "time" ) @@ -13,7 +15,7 @@ type Session struct { Created time.Time } -func NewSession() (session Session, err error) { +func CreateSession() (session Session, err error) {// {{{ var rows *sql.Rows if rows, err = db.Query(` INSERT INTO public.session(uuid) @@ -29,33 +31,60 @@ func NewSession() (session Session, err error) { } return -} +}// }}} -func (session *Session) Retrieve() (err error) { +func sessionUUID(r *http.Request) (string, error) {// {{{ + headers := r.Header["X-Session-Id"] + if len(headers) > 0 { + return headers[0], nil + } + return "", errors.New("Invalid session") +}// }}} +func ValidateSession(r *http.Request, notFoundIsError bool) (session Session, found bool, err error) {// {{{ + var uuid string + if uuid, err = sessionUUID(r); err != nil { + return + } + + session.UUID = uuid + if found, err = session.Retrieve(); err != nil { + return + } + + if notFoundIsError && !found { + err = errors.New("Invalid session") + return + } + + return +}// }}} + +func (session *Session) Retrieve() (found bool, err error) {// {{{ var rows *sql.Rows if rows, err = db.Query(` SELECT uuid, user_id, created FROM public.session WHERE - uuid = $1 + uuid = $1 AND + created + $2::interval >= NOW() `, session.UUID, + fmt.Sprintf("%d days", config.Session.DaysValid), ); err != nil { return } defer rows.Close() + found = false if rows.Next() { + found = true rows.Scan(&session.UUID, &session.UserID, &session.Created) } return -} - -func (session *Session) Authenticate(username, password string) (err error) { - authenticated := false - +}// }}} +func (session *Session) Authenticate(username, password string) (authenticated bool, err error) {// {{{ var rows *sql.Rows if rows, err = db.Query(` SELECT id @@ -81,9 +110,10 @@ func (session *Session) Authenticate(username, password string) (err error) { if err != nil { return } - } else { - err = errors.New("Invalid authentication") } return -} +}// }}} + + +// vim: foldmethod=marker diff --git a/sql/0001.sql b/sql/0001.sql new file mode 100644 index 0000000..0ccc6cd --- /dev/null +++ b/sql/0001.sql @@ -0,0 +1,28 @@ +CREATE TABLE public."user" ( + id SERIAL NOT NULL, + username VARCHAR(64) NOT NULL DEFAULT ''::character varying, + "password" VARCHAR(64) NOT NULL DEFAULT ''::character varying, + last_login TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT newtable_pk PRIMARY KEY (id) +); + +CREATE TABLE public."session" ( + uuid UUID NOT NULL, + user_id INT4 NULL, + created TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT session_pk PRIMARY KEY (uuid), + CONSTRAINT user_session_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE TABLE public.node ( + id SERIAL NOT NULL, + user_id INT4 NOT NULL, + parent_id INT4 NULL, + "name" VARCHAR(256) NOT NULL DEFAULT '', + "content" TEXT NOT NULL DEFAULT '', + CONSTRAINT name_length CHECK (LENGTH(TRIM(name)) > 0), + CONSTRAINT node_pk PRIMARY KEY (id), + CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE RESTRICT ON UPDATE RESTRICT, + CONSTRAINT node_fk FOREIGN KEY (parent_id) REFERENCES public.node(id) ON DELETE RESTRICT ON UPDATE RESTRICT +); + diff --git a/static/css/login.css b/static/css/login.css index 4fdb19f..f940ceb 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -25,3 +25,10 @@ font-size: 0.8em; align-self: center; } +#login .auth-failed { + margin-top: 32px; + color: #a00; + background: #fff; + padding: 16px; + border-radius: 8px; +} diff --git a/static/css/main.css b/static/css/main.css index 39b1a04..7576819 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -3,13 +3,60 @@ body { margin: 0px; padding: 0px; font-family: 'Liberation Mono', monospace; - font-size: 16pt; + font-size: 14pt; background-color: #494949; } h1 { color: #ecbf00; } #app { - padding: 32px; color: #fff; } +.crumbs { + display: flex; + flex-wrap: wrap; + padding: 8px 16px; + background: #ecbf00; + color: #000; + box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.4); +} +.crumbs .crumb { + margin-right: 8px; + font-size: 0.8em; + cursor: pointer; +} +.crumbs .crumb:after { + content: ">"; + margin-left: 8px; + color: #a08100; +} +.crumbs .crumb:last-child { + margin-right: 0; +} +.crumbs .crumb:last-child:after { + content: ''; + margin-left: 0px; +} +.child-nodes { + display: flex; + flex-wrap: wrap; + padding: 16px 16px 0px 16px; + background-color: #3c3c3c; + box-shadow: 0px 0px 16px -4px rgba(0, 0, 0, 0.55) inset; +} +.child-nodes .child-node { + padding: 8px; + border-radius: 8px; + background-color: #292929; + margin-right: 16px; + margin-bottom: 16px; + white-space: nowrap; + font-size: 0.8em; + cursor: pointer; +} +.node-name { + padding: 16px; +} +.node-content { + padding: 32px; +} diff --git a/static/index.html b/static/index.html index dca9637..5c92d11 100644 --- a/static/index.html +++ b/static/index.html @@ -17,7 +17,8 @@ "@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", - "session": "/js/{{ .VERSION }}/session.mjs" + "session": "/js/{{ .VERSION }}/session.mjs", + "node": "/js/{{ .VERSION }}/node.mjs" } } diff --git a/static/js/app.mjs b/static/js/app.mjs index fc0e7b5..b6ecd20 100644 --- a/static/js/app.mjs +++ b/static/js/app.mjs @@ -1,9 +1,9 @@ -import 'preact/debug' import 'preact/devtools' -//import { signal } from 'preact/signals' +import { signal } from 'preact/signals' import { h, Component, render, createRef } from 'preact' import htm from 'htm' import { Session } from 'session' +import { NodeUI } from 'node' const html = htm.bind(h) class App extends Component { @@ -17,18 +17,19 @@ class App extends Component { this.session = new Session(this) this.session.initialize() + this.login = createRef() + this.nodeUI = createRef() }//}}} render() {//{{{ if(!this.session.initialized) { - return false; - //return html`