aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPřemysl Janouch <p@janouch.name>2019-04-13 22:50:08 +0200
committerPřemysl Janouch <p@janouch.name>2019-04-13 23:54:24 +0200
commitbcfb9fbc2bc035149a71df1ffef96f23e668e815 (patch)
tree3a2cad243a1f729cd212fd0432ddbfe7e1362a57
parentf5790dbff9cec6d3fbd09952ba5453ef7a13baf9 (diff)
downloadsklad-bcfb9fbc2bc035149a71df1ffef96f23e668e815.tar.gz
sklad-bcfb9fbc2bc035149a71df1ffef96f23e668e815.tar.xz
sklad-bcfb9fbc2bc035149a71df1ffef96f23e668e815.zip
sklad: implement login and logout
So far there are no other pages, and nothing links to the logout.
-rw-r--r--sklad/base.tmpl25
-rw-r--r--sklad/container.tmpl6
-rw-r--r--sklad/login.tmpl14
-rw-r--r--sklad/main.go108
-rw-r--r--sklad/session.go66
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, &params)
+}
+
+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, &params)
+}
+
+// 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)))
+ }
+}