diff --git a/templates/base.html b/templates/base.html index 5e4779c..37d5967 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,17 +7,20 @@ thead { font-weight: bold; } + #content { float: right; width: 100%; background-color: #F0F0F0; } + #menu { float: left; width: 200px; margin-left: -200px; background-color: #CCCCCC; } + #menu span { display: block; } @@ -29,14 +32,19 @@ {{ block "page-styles" . }}{{ end }} -
- {{ block "page-content" . }}{{ end }} -
- -
+
+ {{ block "page-content" . }}{{ end }} +
+ +
\ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 12669f6..9f37c40 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,3 +1,3 @@ {{ define "page-content" }} - Test {{.foo}} + Startpage. {{ end }} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..a204152 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,24 @@ + + + + + ShareDAV Login + + +
+
+ + +
+ Beware! You need to have cookies enabled for the login to work.
+ The cookie will be used solely for keeping the session alive. +
+ +
+
+ + \ No newline at end of file diff --git a/templates/shares.html b/templates/shares.html index 1231ebc..4b4d846 100644 --- a/templates/shares.html +++ b/templates/shares.html @@ -21,7 +21,7 @@ {{ define "page-content" }}
- {{ range $share := . }} + {{ range $share := .ShareInfos }}
UUID: {{ $share.UUID }}
diff --git a/templates/users.html b/templates/users.html index 32f6bb0..04e64d0 100644 --- a/templates/users.html +++ b/templates/users.html @@ -7,7 +7,7 @@ - {{ range $user := .}} + {{ range $user := .Users }} {{ $user.Username }} {{ $user.Role }} {{ end }} diff --git a/webadmin.go b/webadmin.go index d5d6a4c..ff8f312 100644 --- a/webadmin.go +++ b/webadmin.go @@ -26,19 +26,26 @@ package main import ( + "context" "fmt" "html/template" + "log" "net/http" "path" + "strings" + "time" "github.com/go-chi/chi" uuid "github.com/satori/go.uuid" + "github.com/tidwall/buntdb" + "golang.org/x/crypto/bcrypt" ) type webAdminHandler struct { router chi.Router tplError *template.Template tplConfirm *template.Template + tplLogin *template.Template tplIndex *template.Template tplUsers *template.Template tplShares *template.Template @@ -46,7 +53,26 @@ type webAdminHandler struct { tplCreateShare *template.Template } +type sessionContext struct { + h *webAdminHandler + w http.ResponseWriter + r *http.Request + baseModel map[string]interface{} +} + +const sessionCookieName = "sharedavsession" +const sessionLifetime = 1 * time.Hour + +const confirmRequested = 0 +const confirmAccepted = 1 +const confirmDenied = 2 + func newWebAdminHandler(app *app) *webAdminHandler { + sessionStore, err := buntdb.Open(":memory:") + if err != nil { + panic("cannot initialize session store: " + err.Error()) + } + loadTemplate := func(filename string) *template.Template { t, err := template.ParseFiles( path.Join("templates", "base.html"), @@ -57,10 +83,18 @@ func newWebAdminHandler(app *app) *webAdminHandler { } return t } + loadSingleTemplate := func(filename string) *template.Template { + t, err := template.ParseFiles(path.Join("templates", filename+".html")) + if err != nil { + panic(fmt.Sprintf("cannot load template %q: %v", filename, err)) + } + return t + } h := &webAdminHandler{ tplError: loadTemplate("error"), tplConfirm: loadTemplate("confirm"), + tplLogin: loadSingleTemplate("login"), tplIndex: loadTemplate("index"), tplUsers: loadTemplate("users"), tplShares: loadTemplate("shares"), @@ -68,66 +102,98 @@ func newWebAdminHandler(app *app) *webAdminHandler { tplCreateShare: loadTemplate("create-share"), } - renderPage := func(w http.ResponseWriter, tmpl *template.Template, model interface{}) { - if err := tmpl.Execute(w, model); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - } - - renderError := func(w http.ResponseWriter, msg, returnURL string) { - model := struct { - ErrorMessage string - ReturnURL string - }{ - ErrorMessage: msg, - ReturnURL: returnURL, - } - - renderPage(w, h.tplError, model) - } - - const confirmRequested = 0 - const confirmAccepted = 1 - const confirmDenied = 2 - confirm := func(w http.ResponseWriter, r *http.Request, msg template.HTML, returnURL string) int { - if r.FormValue("_yes") != "" { - return confirmAccepted - } else if r.FormValue("_no") != "" { - return confirmDenied - } else { - model := struct { - URL string - Fields map[string]string - Message template.HTML - }{ - URL: returnURL, - Fields: make(map[string]string), - Message: msg, - } - - for k, v := range r.Form { - model.Fields[k] = v[0] - } - - renderPage(w, h.tplConfirm, model) - return confirmRequested - } - } - r := chi.NewRouter() - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, h.tplIndex, map[string]string{"foo": "bar"}) + r.Use(disableCaching) + + r.Route("/login", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + sessionContext.RenderPage(h.tplLogin, nil) + }) + r.Post("/", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + + username := r.FormValue("username") + if strings.ContainsAny(username, "*:") { + sessionContext.RenderError("Username or password wrong.", "login") + return + } + + user, err := app.userStore.GetUser(username) + if err != nil { + sessionContext.RenderError("Username or password wrong.", "login") + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); err != nil { + sessionContext.RenderError("Username or password wrong.", "login") + return + } + + sessionId := uuid.NewV4() + if err := sessionStore.Update(func(tx *buntdb.Tx) error { + _, _, err := tx.Set(sessionId.String(), user.Username, &buntdb.SetOptions{ + Expires: true, + TTL: sessionLifetime, + }) + return err + }); err != nil { + log.Printf("error setting session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + cookie := &http.Cookie{ + Name: sessionCookieName, + Value: sessionId.String(), + Expires: time.Now().Add(sessionLifetime), + HttpOnly: true, + } + http.SetCookie(w, cookie) + + sessionContext.Redirect("./") + }) }) - r.Get("/users", func(w http.ResponseWriter, r *http.Request) { + + ar := r.With(authenticated(sessionStore, app.userStore)) + + ar.Get("/logout", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + sessionCookie, err := r.Cookie(sessionCookieName) + if err != nil { + http.Redirect(w, r, "login", http.StatusFound) + return + } + if err := sessionStore.Update(func(tx *buntdb.Tx) error { + _, err := tx.Delete(sessionCookie.Value) + return err + }); err != nil { + log.Printf("error unsetting session: %v\n", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + sessionContext.Redirect("./") + }) + ar.Get("/", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + sessionContext.RenderPage(h.tplIndex, nil) + }) + ar.Get("/users", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + users, err := app.userStore.GetUsers() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - renderPage(w, h.tplUsers, users) + sessionContext.RenderPage(h.tplUsers, map[string]interface{}{ + "Users": users, + }) }) - r.Get("/shares", func(w http.ResponseWriter, r *http.Request) { + ar.Get("/shares", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + shares, err := app.shareStore.GetShares() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -150,10 +216,14 @@ func newWebAdminHandler(app *app) *webAdminHandler { shareInfos[i].Users = users } - renderPage(w, h.tplShares, shareInfos) + sessionContext.RenderPage(h.tplShares, map[string]interface{}{ + "ShareInfos": shareInfos, + }) }) - r.Route("/share-add-user", func(r chi.Router) { + ar.Route("/share-add-user", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + shareId := r.URL.Query().Get("share") if shareId == "" { http.Error(w, "invalid share id", http.StatusBadRequest) @@ -166,67 +236,72 @@ func newWebAdminHandler(app *app) *webAdminHandler { return } - model := map[string]interface{}{ + sessionContext.RenderPage(h.tplShareAddUser, map[string]interface{}{ "ShareId": shareId, "Users": users, - } - - renderPage(w, h.tplShareAddUser, model) + }) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + returnURL := "share-add-user?share=" + r.FormValue("share") shareId, err := uuid.FromString(r.FormValue("share")) if err != nil { - renderError(w, "Internal error: "+err.Error(), "") + sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } share := Share{UUID: shareId} user, err := app.userStore.GetUser(r.FormValue("user")) if err == ErrUserNotFound { - renderError(w, "User not found.", returnURL) + sessionContext.RenderError("User not found.", returnURL) return } else if err != nil { - renderError(w, "Internal error: "+err.Error(), "") + sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } err = app.shareStore.AddUserToShare(share, user.Username, ShareRole(r.FormValue("role"))) if err != nil { - renderError(w, "Cannot add user to share: "+err.Error(), returnURL) + sessionContext.RenderError(template.HTML("Cannot add user to share: "+err.Error()), returnURL) return } - http.Redirect(w, r, "shares", http.StatusFound) + sessionContext.Redirect("shares") }) }) - r.Post("/share-delete-user", func(w http.ResponseWriter, r *http.Request) { + ar.Post("/share-delete-user", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + returnURL := "shares" shareId, err := uuid.FromString(r.FormValue("share")) if err != nil { - renderError(w, "Internal error: "+err.Error(), "") + sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } share := Share{UUID: shareId} err = app.shareStore.RemoveUserFromShare(share, r.FormValue("user")) if err != nil { - renderError(w, "Cannot remove user from share: "+err.Error(), returnURL) + sessionContext.RenderError(template.HTML("Cannot remove user from share: "+err.Error()), returnURL) return } http.Redirect(w, r, "shares", http.StatusFound) }) - r.Route("/create-share", func(r chi.Router) { + ar.Route("/create-share", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { - renderPage(w, h.tplCreateShare, nil) + sessionContext := h.buildSessionContext(w, r) + sessionContext.RenderPage(h.tplCreateShare, nil) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + share, err := app.shareStore.CreateShare() if err != nil { - renderError(w, "Cannot create share: "+err.Error(), "") + sessionContext.RenderError(template.HTML("Cannot create share: "+err.Error()), "") return } @@ -234,39 +309,152 @@ func newWebAdminHandler(app *app) *webAdminHandler { share.Description = r.FormValue("description") if err := app.shareStore.UpdateShareAttributes(share); err != nil { - renderError(w, "Cannot update share: "+err.Error(), "") + sessionContext.RenderError(template.HTML("Cannot update share: "+err.Error()), "") return } - http.Redirect(w, r, "shares#share-"+share.UUID.String(), http.StatusFound) + sessionContext.Redirect("shares#share-" + share.UUID.String()) }) }) - r.Post("/delete-share", func(w http.ResponseWriter, r *http.Request) { + ar.Post("/delete-share", func(w http.ResponseWriter, r *http.Request) { + sessionContext := h.buildSessionContext(w, r) + share, err := app.shareStore.GetShare(r.FormValue("share")) if err != nil { - renderError(w, "Internal error: "+err.Error(), "") + sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } message := fmt.Sprintf(`You are about to delete the share %s (%s).
This will delete all data permanently.

Are you sure you want to continue?`, share.UUID, share.Name) - if confirmStatus := confirm(w, r, template.HTML(message), "delete-share"); confirmStatus == confirmRequested { + if confirmStatus := sessionContext.RequestConfirmation(template.HTML(message), "delete-share"); confirmStatus == confirmRequested { // We have already rendered. Nothing to do. return } else if confirmStatus == confirmAccepted { if err := app.shareStore.RemoveShare(share.UUID); err != nil { - renderError(w, "Share cannot be removed: "+err.Error(), "shares") + sessionContext.RenderError(template.HTML("Share cannot be removed: "+err.Error()), "shares") return } } - http.Redirect(w, r, "shares", http.StatusFound) + sessionContext.Redirect("shares") }) h.router = r return h } -func (h webAdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (h *webAdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.router.ServeHTTP(w, r) } + +func (h *webAdminHandler) buildSessionContext(w http.ResponseWriter, r *http.Request) *sessionContext { + sessionContext := &sessionContext{ + h: h, + w: w, + r: r, + baseModel: map[string]interface{}{}, + } + sessionUser := userFromContext(r) + if sessionUser != nil { + sessionContext.baseModel["SessionUser"] = sessionUser + } + return sessionContext +} + +func (s *sessionContext) Redirect(target string) { + http.Redirect(s.w, s.r, target, http.StatusFound) +} + +func (s *sessionContext) RenderPage(tmpl *template.Template, model map[string]interface{}) { + effectiveModel := map[string]interface{}{} + for k, v := range s.baseModel { + effectiveModel[k] = v + } + for k, v := range model { + effectiveModel[k] = v + } + if err := tmpl.Execute(s.w, effectiveModel); err != nil { + http.Error(s.w, err.Error(), http.StatusInternalServerError) + } +} + +func (s *sessionContext) RenderError(msg template.HTML, returnURL string) { + model := map[string]interface{}{ + "ErrorMessage": msg, + "ReturnURL": returnURL, + } + s.RenderPage(s.h.tplError, model) +} + +func (s *sessionContext) RequestConfirmation(msg template.HTML, returnURL string) int { + if s.r.FormValue("_yes") != "" { + return confirmAccepted + } else if s.r.FormValue("_no") != "" { + return confirmDenied + } else { + fields := map[string]string{} + for k, v := range s.r.Form { + fields[k] = v[0] + } + model := map[string]interface{}{ + "URL": returnURL, + "Message": msg, + "Fields": fields, + } + + s.RenderPage(s.h.tplConfirm, model) + return confirmRequested + } +} + +func disableCaching(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Header().Set("Pragma", "no-cache") + w.Header().Set("Expires", "0") + + next.ServeHTTP(w, r) + }) +} + +func authenticated(sessionStore *buntdb.DB, userStore UserStore) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sessionCookie, err := r.Cookie(sessionCookieName) + if err != nil { + http.Redirect(w, r, "login", http.StatusFound) + return + } + var username string + if err := sessionStore.View(func(tx *buntdb.Tx) error { + val, err := tx.Get(sessionCookie.Value) + if err != nil { + return err + } + + username = val + return nil + }); err != nil { + http.Redirect(w, r, "login", http.StatusFound) + return + } + + user, err := userStore.GetUser(username) + if err != nil { + http.Redirect(w, r, "login", http.StatusFound) + return + } + + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "user", &user))) + }) + } +} + +func userFromContext(r *http.Request) *User { + if user, ok := r.Context().Value("user").(*User); ok { + return user + } else { + return nil + } +}