diff options
-rw-r--r-- | sklad/base.tmpl | 25 | ||||
-rw-r--r-- | sklad/container.tmpl | 6 | ||||
-rw-r--r-- | sklad/login.tmpl | 14 | ||||
-rw-r--r-- | sklad/main.go | 108 | ||||
-rw-r--r-- | sklad/session.go | 66 |
5 files changed, 207 insertions, 12 deletions
diff --git a/sklad/base.tmpl b/sklad/base.tmpl new file mode 100644 index 0000000..2070d6e --- /dev/null +++ b/sklad/base.tmpl @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html> +<head> + <title>{{ template "Title" }} - sklad</title> + <meta http-equiv=Content-Type content="text/html; charset=utf-8"> + <style> + html, body { min-height: 100vh; } + body { padding: 1em; box-sizing: border-box; + margin: 0 auto; max-width: 50em; + border-left: 1px solid gray; border-right: 1px solid gray; + font-family: sans-serif; } + </style> +</head> +<body> +<h1>sklad</h1> + +{{ if .LoggedIn }} +<form method=post action=/logout> +<input type=submit value="Odhlásit"> +</form> +{{ end }} + +{{ template "Content" . }} +</body> +</html> diff --git a/sklad/container.tmpl b/sklad/container.tmpl new file mode 100644 index 0000000..341d19b --- /dev/null +++ b/sklad/container.tmpl @@ -0,0 +1,6 @@ +{{ define "Title" }}Přehled{{ end }} +{{ define "Content" }} + +<p>TODO + +{{ end }} diff --git a/sklad/login.tmpl b/sklad/login.tmpl new file mode 100644 index 0000000..4df6c3a --- /dev/null +++ b/sklad/login.tmpl @@ -0,0 +1,14 @@ +{{ define "Title" }}Přihlášení{{ end }} +{{ define "Content" }} + +<form method=post> +<label for=password>Heslo:</label> +<input type=password name=password id=password> +<input type=submit value="Přihlásit"> +</form> + +{{ if .IncorrectPassword }} +<p>Bylo zadáno nesprávné heslo. +{{ end }} + +{{ end }} diff --git a/sklad/main.go b/sklad/main.go index c1eaa99..3ceea9f 100644 --- a/sklad/main.go +++ b/sklad/main.go @@ -2,21 +2,100 @@ package main import ( "html/template" + "io" "log" + "math/rand" "net/http" "os" + "path/filepath" + "time" ) var ( - templates *template.Template - - // session storage: UUID -> net.SplitHostPort(http.Server.RemoteAddr)[0] - sessions = map[string]string{} + templates = map[string]*template.Template{} ) +func executeTemplate(name string, w io.Writer, data interface{}) { + if err := templates[name].Execute(w, data); err != nil { + panic(err) + } +} + +func handleLogin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + redirect := r.FormValue("redirect") + if redirect == "" { + redirect = "/" + } + + session := sessionGet(w, r) + if session.LoggedIn { + http.Redirect(w, r, redirect, http.StatusSeeOther) + return + } + + params := struct { + LoggedIn bool + IncorrectPassword bool + }{} + + switch r.Method { + case http.MethodGet: + w.Header().Set("Cache-Control", "no-store") + case http.MethodPost: + if r.FormValue("password") == db.Password { + session.LoggedIn = true + http.Redirect(w, r, redirect, http.StatusSeeOther) + return + } + params.IncorrectPassword = true + default: + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + executeTemplate("login.tmpl", w, ¶ms) +} + +func handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + session := r.Context().Value(sessionContextKey{}).(*Session) + session.LoggedIn = false + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func handleContainer(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + params := struct { + LoggedIn bool + }{ + LoggedIn: true, + } + + executeTemplate("container.tmpl", w, ¶ms) +} + +// TODO: Consider a wrapper function that automatically calls ParseForm +// and disables client-side caching. + func main() { + // Randomize the RNG for session string generation. + rand.Seed(time.Now().UnixNano()) + if len(os.Args) != 3 { - log.Fatalf("usage: %s ADDRESS DATABASE\n", os.Args[0]) + log.Fatalf("Usage: %s ADDRESS DATABASE-FILE\n", os.Args[0]) } var address string @@ -28,26 +107,31 @@ func main() { } // Load HTML templates from the current working directory. - var err error - templates, err = template.ParseGlob("*.tmpl") + m, err := filepath.Glob("*.tmpl") if err != nil { log.Fatalln(err) } + for _, name := range m { + templates[name] = template.Must(template.ParseFiles("base.tmpl", name)) + } // TODO: Eventually we will need to load a font file for label printing. // - The path might be part of configuration, or implicit by filename. - // TODO: Some routing, don't forget about sessions. - // - https://stackoverflow.com/a/33880971/76313 + // TODO: Some routing and pages. // - // - GET /login // - GET /container?id=UA1 // - GET /series?id=A // - GET /search?q=bottle // - // - POST /login?pass=hue - // - POST /logout + // - https://stackoverflow.com/a/33880971/76313 // - POST /label?id=UA1 + http.HandleFunc("/", sessionWrap(handleContainer)) + http.HandleFunc("/container", sessionWrap(handleContainer)) + + http.HandleFunc("/login", handleLogin) + http.HandleFunc("/logout", sessionWrap(handleLogout)) + log.Fatalln(http.ListenAndServe(address, nil)) } diff --git a/sklad/session.go b/sklad/session.go new file mode 100644 index 0000000..0d0686a --- /dev/null +++ b/sklad/session.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "encoding/hex" + "math/rand" + "net/http" + "net/url" +) + +// session storage indexed by a random UUID +var sessions = map[string]*Session{} + +type Session struct { + LoggedIn bool // may access the DB +} + +type sessionContextKey struct{} + +func sessionGenId() string { + u := make([]byte, 16) + if _, err := rand.Read(u); err != nil { + panic("cannot generate random bytes") + } + return hex.EncodeToString(u) +} + +// TODO: We don't want to keep an unlimited amount of cookies in the storage. +// - The essential question is: how do we avoid DoS? +// - Which cookies are worth keeping? +// - Definitely logged-in users, only one person should know the password. +// - Evict by FIFO? LRU? +func sessionGet(w http.ResponseWriter, r *http.Request) (session *Session) { + if c, _ := r.Cookie("sessionid"); c != nil { + session, _ = sessions[c.Value] + } + if session == nil { + id := sessionGenId() + session = &Session{LoggedIn: false} + sessions[id] = session + http.SetCookie(w, &http.Cookie{Name: "sessionid", Value: id}) + } + return +} + +func sessionWrap(inner func(http.ResponseWriter, *http.Request)) func( + http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + // We might also try no-cache with an ETag for the whole database, + // though I don't expect any substantial improvements of anything. + w.Header().Set("Cache-Control", "no-store") + + redirect := "/login" + if r.RequestURI != "/" { + redirect += "?redirect=" + url.QueryEscape(r.RequestURI) + } + + session := sessionGet(w, r) + if !session.LoggedIn { + http.Redirect(w, r, redirect, http.StatusSeeOther) + return + } + inner(w, r.WithContext( + context.WithValue(r.Context(), sessionContextKey{}, session))) + } +} |