From 4eef81c07f14773e11d37c69167fa31c07ba35e8 Mon Sep 17 00:00:00 2001 From: Noah Date: Mon, 25 Nov 2019 19:55:28 -0800 Subject: [PATCH] Sessions, log in and out --- go.mod | 2 + go.sum | 4 ++ pkg/app.go | 1 + pkg/authentication/login.go | 87 ++++++++++++++++++++++++++ pkg/controllers/authentication.go | 66 +++++++++++++++++++ pkg/controllers/initial_setup.go | 57 ++++++++++++----- pkg/controllers/static_files.go | 1 - pkg/models/users.go | 63 +++++++++++++++---- pkg/responses/errors.go | 10 ++- pkg/responses/filesystem.go | 82 ++++++++++++++++++++++++ pkg/responses/redirect.go | 9 +++ pkg/responses/template_functions.go | 42 +++++++++---- pkg/responses/template_vars.go | 77 +++++++++++++++++++++++ pkg/responses/templates.go | 82 ++---------------------- pkg/routes.go | 4 ++ pkg/session/keys.go | 11 ++++ pkg/session/sessions.go | 86 +++++++++++++++++++++++++ pkg/settings/settings.go | 69 ++++++++++++++++++++ pvt-www/.layout.gohtml | 20 ++++-- pvt-www/_builtin/errors/generic.gohtml | 9 +++ pvt-www/_builtin/initial_setup.gohtml | 22 +++---- pvt-www/_builtin/users/login.gohtml | 34 ++++++++++ 22 files changed, 703 insertions(+), 135 deletions(-) create mode 100644 pkg/authentication/login.go create mode 100644 pkg/controllers/authentication.go create mode 100644 pkg/responses/filesystem.go create mode 100644 pkg/responses/redirect.go create mode 100644 pkg/responses/template_vars.go create mode 100644 pkg/session/keys.go create mode 100644 pkg/session/sessions.go create mode 100644 pkg/settings/settings.go create mode 100644 pvt-www/_builtin/errors/generic.gohtml create mode 100644 pvt-www/_builtin/users/login.gohtml diff --git a/go.mod b/go.mod index dfe22b5..3a1b71d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module git.kirsle.net/apps/gophertype go 1.13 require ( + github.com/albrow/forms v0.3.3 github.com/gorilla/mux v1.7.3 + github.com/gorilla/sessions v1.2.0 github.com/jinzhu/gorm v1.9.11 github.com/kirsle/blog v0.0.0-20191022175051-d78814b9c99b github.com/satori/go.uuid v1.2.0 diff --git a/go.sum b/go.sum index 88ff864..b9f2d14 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7h github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/albrow/forms v0.3.3 h1:+40fCsDyS2lU97IEeed7bnUGENvlVzppQGBGy6kd77E= +github.com/albrow/forms v0.3.3/go.mod h1:jvrM3b0gPuIRiY1E/KmKfPk2XXDEKj7yFB+g9g0BItQ= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= @@ -49,6 +51,8 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= +github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= +github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jinzhu/gorm v1.9.9/go.mod h1:Kh6hTsSGffh4ui079FHrR5Gg+5D0hgihqDcsDN2BBJY= diff --git a/pkg/app.go b/pkg/app.go index facd8fc..58c7b14 100644 --- a/pkg/app.go +++ b/pkg/app.go @@ -42,5 +42,6 @@ func (s *Site) UseDB(driver string, path string) error { // ListenAndServe starts the HTTP service. func (s *Site) ListenAndServe(addr string) error { + log.Printf("Listening on %s", addr) return http.ListenAndServe(addr, s.n) } diff --git a/pkg/authentication/login.go b/pkg/authentication/login.go new file mode 100644 index 0000000..f2701d7 --- /dev/null +++ b/pkg/authentication/login.go @@ -0,0 +1,87 @@ +package authentication + +import ( + "context" + "errors" + "log" + "net/http" + + "git.kirsle.net/apps/gophertype/pkg/models" + "git.kirsle.net/apps/gophertype/pkg/session" +) + +// CurrentUser returns the currently logged-in user in the browser session. +func CurrentUser(r *http.Request) (models.User, error) { + sess := session.Get(r) + if loggedIn, ok := sess.Values["logged-in"].(bool); ok && loggedIn { + id := sess.Values["user-id"].(int) + user, err := models.GetUserByID(id) + return user, err + } + return models.User{}, errors.New("not logged in") +} + +// Login logs the browser session in as the user. +func Login(w http.ResponseWriter, r *http.Request, user models.User) { + sess := session.Get(r) + + sess.Values["logged-in"] = true + sess.Values["user-id"] = int(user.ID) + if err := sess.Save(r, w); err != nil { + log.Printf("ERROR: Login() Session error: " + err.Error()) + } +} + +// Logout logs the current user out. +func Logout(w http.ResponseWriter, r *http.Request) { + sess := session.Get(r) + sess.Values["logged-in"] = false + sess.Values["user-id"] = 0 + sess.Save(r, w) +} + +// LoggedIn returns whether the session is logged in as a user. +func LoggedIn(r *http.Request) bool { + sess := session.Get(r) + if v, ok := sess.Values["logged-in"].(bool); ok && v == true { + return true + } + return false +} + +// LoginRequired is a middleware for authenticated endpoints. +func LoginRequired(next http.Handler) http.Handler { + middleware := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + if user, ok := ctx.Value(session.UserKey).(models.User); ok { + if user.ID > 0 { + next.ServeHTTP(w, r) + return + } + } + + // Redirect to the login page. + w.Header().Set("Location", "/login?next="+r.URL.Path) + w.WriteHeader(http.StatusFound) + } + + return http.HandlerFunc(middleware) +} + +// Middleware checks the authentication and loads the user onto the request context. +func Middleware(next http.Handler) http.Handler { + middleware := func(w http.ResponseWriter, r *http.Request) { + user, err := CurrentUser(r) + if err != nil { + // User not logged in, go to next middleware. + next.ServeHTTP(w, r) + return + } + + // Put the CurrentUser into the request context. + ctx := context.WithValue(r.Context(), session.UserKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + } + + return http.HandlerFunc(middleware) +} diff --git a/pkg/controllers/authentication.go b/pkg/controllers/authentication.go new file mode 100644 index 0000000..f673f38 --- /dev/null +++ b/pkg/controllers/authentication.go @@ -0,0 +1,66 @@ +package controllers + +import ( + "log" + "net/http" + + "git.kirsle.net/apps/gophertype/pkg/authentication" + "git.kirsle.net/apps/gophertype/pkg/glue" + "git.kirsle.net/apps/gophertype/pkg/models" + "git.kirsle.net/apps/gophertype/pkg/responses" + "git.kirsle.net/apps/gophertype/pkg/session" + "github.com/albrow/forms" +) + +func init() { + glue.Register(glue.Endpoint{ + Path: "/login", + Methods: []string{"GET", "POST"}, + Handler: func(w http.ResponseWriter, r *http.Request) { + // Template variables. + v := responses.NewTemplateVars(w, r) + + // POST handler: create the admin account. + for r.Method == http.MethodPost { + form, _ := forms.Parse(r) + v.FormValues = form.Values + + // Validate form parameters. + val := form.Validator() + val.Require("email") + val.MatchEmail("email") + val.Require("password") + if val.HasErrors() { + v.ValidationError = val.ErrorMap() + log.Printf("validation: %+v", v.ValidationError) + break + } + + // Check authentication. + user, err := models.AuthenticateUser(form.Get("email"), form.Get("password")) + if err != nil { + v.Error = err + break + } + + _ = user + + authentication.Login(w, r, user) + session.Flash(w, r, "Signed in!") + responses.Redirect(w, r, "/") // TODO: next URL + return + } + + responses.RenderTemplate(w, r, "_builtin/users/login.gohtml", v) + }, + }) + + glue.Register(glue.Endpoint{ + Path: "/logout", + Handler: func(w http.ResponseWriter, r *http.Request) { + authentication.Logout(w, r) + session.Flash(w, r, "Signed out!") + responses.Redirect(w, r, "/") + }, + }) +} diff --git a/pkg/controllers/initial_setup.go b/pkg/controllers/initial_setup.go index 13c4d1e..b6e6093 100644 --- a/pkg/controllers/initial_setup.go +++ b/pkg/controllers/initial_setup.go @@ -3,6 +3,7 @@ package controllers import ( "errors" "fmt" + "log" "net/http" "git.kirsle.net/apps/gophertype/pkg/constants" @@ -10,6 +11,8 @@ import ( "git.kirsle.net/apps/gophertype/pkg/middleware" "git.kirsle.net/apps/gophertype/pkg/models" "git.kirsle.net/apps/gophertype/pkg/responses" + "git.kirsle.net/apps/gophertype/pkg/settings" + "github.com/albrow/forms" "github.com/gorilla/mux" ) @@ -28,43 +31,69 @@ func init() { } // Template variables. - v := map[string]interface{}{} + v := responses.NewTemplateVars(w, r) + v.SetupNeeded = false // supress the banner on this page. // POST handler: create the admin account. - if r.Method == http.MethodPost { + for r.Method == http.MethodPost { + form, err := forms.Parse(r) + if err != nil { + responses.Error(w, r, http.StatusBadRequest, err.Error()) + return + } + + v.FormValues = form.Values + + // Validate form parameters. + val := form.Validator() + val.Require("email") + val.MatchEmail("email") + val.MinLength("password", 8) + val.Require("password2") + val.Equal("password", "password2") + if val.HasErrors() { + v.Error = fmt.Errorf("validation error") + v.ValidationError = val.ErrorMap() + log.Printf("validation: %+v", v.ValidationError) + break + } + var ( - username = r.FormValue("username") - displayName = r.FormValue("name") - password = r.FormValue("password") - password2 = r.FormValue("password2") + email = form.Get("email") + displayName = form.Get("name") + password = form.Get("password") + password2 = form.Get("password2") ) // Username and display name validation happens in CreateUser. // Validate the passwords match here. if len(password) < constants.PasswordMinLength { - v["Error"] = fmt.Errorf("your password is too short (must be %d+ characters)", constants.PasswordMinLength) + v.Error = fmt.Errorf("your password is too short (must be %d+ characters)", constants.PasswordMinLength) } if password != password2 { - v["Error"] = errors.New("your passwords don't match") + v.Error = errors.New("your passwords don't match") } else { admin := models.User{ - Username: username, - Name: displayName, - IsAdmin: true, + Email: email, + Name: displayName, + IsAdmin: true, } admin.SetPassword(password) if err := models.CreateUser(admin); err != nil { - v["Error"] = err + v.Error = err } else { // Admin created! Make the default config. - cfg := models.GetSettings() + cfg := settings.Load() + cfg.Initialized = true cfg.Save() - w.Write([]byte("Success")) + responses.Redirect(w, r, "/login") return } } + + break } responses.RenderTemplate(w, r, "_builtin/initial_setup.gohtml", v) diff --git a/pkg/controllers/static_files.go b/pkg/controllers/static_files.go index 65c06f4..305a295 100644 --- a/pkg/controllers/static_files.go +++ b/pkg/controllers/static_files.go @@ -30,7 +30,6 @@ func CatchAllHandler(w http.ResponseWriter, r *http.Request) { // Is it a Go template? if strings.HasSuffix(filepath, ".gohtml") { - log.Printf("Resolved to Go Template path %s", filepath) responses.RenderTemplate(w, r, filepath, nil) return } diff --git a/pkg/models/users.go b/pkg/models/users.go index a903aa4..3673c6e 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -3,6 +3,7 @@ package models import ( "errors" "fmt" + "log" "strings" "git.kirsle.net/apps/gophertype/pkg/constants" @@ -13,30 +14,53 @@ import ( // User account for the site. type User struct { gorm.Model - Username string `json:"username" gorm:"unique_index"` + Email string `json:"email" gorm:"unique_index"` + Name string `json:"name"` HashedPassword string `json:"-"` IsAdmin bool `json:"isAdmin" gorm:"index"` - Name string `json:"name"` - Email string `json:"email" gorm:"index"` } // Validate the User object has everything filled in. Fixes what it can, // returns an error if something is wrong. Ensures the HashedPassword is hashed. func (u *User) Validate() error { - u.Username = strings.TrimSpace(strings.ToLower(u.Username)) - u.Name = strings.TrimSpace(strings.ToLower(u.Name)) + u.Email = strings.TrimSpace(strings.ToLower(u.Email)) + u.Name = strings.TrimSpace(u.Name) - // Defaults - if len(u.Name) == 0 { - u.Name = u.Username - } - - if len(u.Username) == 0 { - return errors.New("username is required") + if len(u.Email) == 0 { + return errors.New("Email is required") } return nil } +// AuthenticateUser checks a login for an email and password. +func AuthenticateUser(email string, password string) (User, error) { + user, err := GetUserByEmail(email) + if err != nil { + log.Printf("ERROR: AuthenticateUser: email %s not found: %s", email, err) + return User{}, errors.New("incorrect email or password") + } + + if user.VerifyPassword(password) { + return user, nil + } + + return User{}, errors.New("incorrect email or password") +} + +// GetUserByID looks up a user by their ID. +func GetUserByID(id int) (User, error) { + var user User + r := DB.First(&user, id) + return user, r.Error +} + +// GetUserByEmail looks up a user by their email address. +func GetUserByEmail(email string) (User, error) { + var user User + r := DB.Where("email = ?", strings.ToLower(email)).First(&user) + return user, r.Error +} + // SetPassword stores the hashed password for a user. func (u *User) SetPassword(password string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), constants.BcryptCost) @@ -49,6 +73,21 @@ func (u *User) SetPassword(password string) error { return nil } +// VerifyPassword checks if the password matches the user's hashed password. +func (u *User) VerifyPassword(password string) bool { + if u.HashedPassword == "" { + fmt.Printf("ERROR: VerifyPassword: user has no HashedPassword") + return false + } + + err := bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(password)) + if err == nil { + return true + } + log.Printf("ERROR: VerifyPassword: %s", err) + return false +} + // FirstAdmin returns the admin user with the lowest ID number. func FirstAdmin() (User, error) { var user User diff --git a/pkg/responses/errors.go b/pkg/responses/errors.go index 3de3773..fc5cf85 100644 --- a/pkg/responses/errors.go +++ b/pkg/responses/errors.go @@ -28,6 +28,14 @@ func Error(w http.ResponseWriter, r *http.Request, code int, message string) { func NotFound(w http.ResponseWriter, r *http.Request) { if err := RenderTemplate(w, r, "_builtin/errors/404.gohtml", nil); err != nil { log.Printf("responses.NotFound: failed to render a pretty error template: %s", err) - w.Write([]byte("Not Found")) + Panic(w, http.StatusNotFound, "Not Found") + } +} + +// BadRequest returns a 400 Bad Request page. +func BadRequest(w http.ResponseWriter, r *http.Request, vars interface{}) { + if err := RenderTemplate(w, r, "_builtin/errors/generic.gohtml", vars); err != nil { + log.Printf("responses.NotFound: failed to render a pretty error template: %s", err) + Panic(w, http.StatusBadRequest, "Bad Request") } } diff --git a/pkg/responses/filesystem.go b/pkg/responses/filesystem.go new file mode 100644 index 0000000..e944c5c --- /dev/null +++ b/pkg/responses/filesystem.go @@ -0,0 +1,82 @@ +package responses + +import ( + "errors" + "io/ioutil" + "os" + "strings" + + "git.kirsle.net/apps/gophertype/pkg/bundled" +) + +// GetFile returns the template file's data, wherever it is. +// Checks the embedded bindata, then the user root on disk, then error. +// If it can be found, returns the contents or error. +func GetFile(path string) ([]byte, error) { + // Check bindata. + if b, err := bundled.Asset(path); err == nil { + return b, nil + } + + // Check the filesystem. TODO + if b, err := ioutil.ReadFile("./pvt-www/" + path); err == nil { + return b, nil + } else { + return []byte{}, err + } +} + +// GetFileExists checks if the file exists but doesn't return its data. +func GetFileExists(path string) bool { + // Check bindata. + if _, err := bundled.AssetInfo(path); err == nil { + return true + } + + // Check the filesystem. TODO + if _, err := os.Stat(path); err == nil { + return true + } + + return false +} + +/* +ResolveFile searches for the existence of a file from a fuzzy URL path. + +`path` is a request path like "/about" + +This function would return e.g. "about.gohtml" as being a file path that is +sure to return data in GetFile(). + +Path finding rules follow expected behavior from dominant web servers: + +- If the exact path is found, return it immediately. +- Try assuming a ".gohtml" or ".md" file extension for the path. +- Try checking if the path is a directory with an "index.gohtml" inside it, etc. +*/ +func ResolveFile(path string) (string, error) { + // Ensure the path doesn't begin with a slash. + path = strings.TrimLeft(path, "/") + + // Try the exact path. + if GetFileExists(path) { + return path, nil + } + + // Try fuzzy file matches. + var tries = []string{ + path + ".gohtml", + path + ".md", + path + "/index.gohtml", + path + "/index.html", + } + for _, try := range tries { + path = strings.TrimLeft(try, "/") + if GetFileExists(path) { + return path, nil + } + } + + return "", errors.New("not found") +} diff --git a/pkg/responses/redirect.go b/pkg/responses/redirect.go new file mode 100644 index 0000000..d55f2b6 --- /dev/null +++ b/pkg/responses/redirect.go @@ -0,0 +1,9 @@ +package responses + +import "net/http" + +// Redirect to a different URL. +func Redirect(w http.ResponseWriter, r *http.Request, url string) { + w.Header().Set("Location", url) + w.WriteHeader(http.StatusFound) +} diff --git a/pkg/responses/template_functions.go b/pkg/responses/template_functions.go index e827155..bad85ec 100644 --- a/pkg/responses/template_functions.go +++ b/pkg/responses/template_functions.go @@ -11,17 +11,35 @@ import ( // TemplateFuncs available to all templates. func TemplateFuncs(r *http.Request) template.FuncMap { return template.FuncMap{ - "CSRF": func() template.HTML { - fmt.Println("CSRF() func called") - token, _ := r.Cookie(constants.CSRFCookieName) - return template.HTML(fmt.Sprintf( - ``, - constants.CSRFFormName, - token.Value, - )) - }, - "TestFunction": func() template.HTML { - return template.HTML("Testing") - }, + "CSRF": CSRF(r), + "FormValue": FormValue(r), + "TestFunction": TestFunction(r), + } +} + +// CSRF returns the current CSRF token as an HTML hidden form field. +func CSRF(r *http.Request) func() template.HTML { + return func() template.HTML { + fmt.Println("CSRF() func called") + token, _ := r.Cookie(constants.CSRFCookieName) + return template.HTML(fmt.Sprintf( + ``, + constants.CSRFFormName, + token.Value, + )) + } +} + +// FormValue returns a form value (1st item only). +func FormValue(r *http.Request) func(string) string { + return func(key string) string { + return r.FormValue(key) + } +} + +// TestFunction is a "hello world" template function. +func TestFunction(r *http.Request) func() template.HTML { + return func() template.HTML { + return template.HTML("TestFunction() called") } } diff --git a/pkg/responses/template_vars.go b/pkg/responses/template_vars.go new file mode 100644 index 0000000..8d5c697 --- /dev/null +++ b/pkg/responses/template_vars.go @@ -0,0 +1,77 @@ +package responses + +import ( + "fmt" + "net/http" + "net/url" + "time" + + "git.kirsle.net/apps/gophertype/pkg/authentication" + "git.kirsle.net/apps/gophertype/pkg/models" + "git.kirsle.net/apps/gophertype/pkg/session" + "git.kirsle.net/apps/gophertype/pkg/settings" + "github.com/gorilla/sessions" +) + +// NewTemplateVars creates the TemplateVars for your current request. +func NewTemplateVars(w http.ResponseWriter, r *http.Request) TemplateValues { + var s = settings.Current + user, _ := authentication.CurrentUser(r) + + v := TemplateValues{ + SetupNeeded: !s.Initialized, + + Title: s.Title, + Description: s.Description, + + Request: r, + RequestTime: time.Now(), + RequestDuration: time.Duration(0), + Path: r.URL.Path, + + LoggedIn: authentication.LoggedIn(r), + IsAdmin: user.IsAdmin, + CurrentUser: user, + + Flashes: session.GetFlashes(w, r), + } + return v +} + +// TemplateValues holds the context for html templates. +type TemplateValues struct { + // When the site needs the initial config. + SetupNeeded bool + + // Config values available as template variables. + Title string + Description string + + // Request variables + Request *http.Request + RequestTime time.Time + RequestDuration time.Duration + FormValues url.Values + Path string // request path + TemplatePath string // file path of html template, like "_builtin/error.gohtml" + + // Session variables + Session *sessions.Session + LoggedIn bool + IsAdmin bool + CurrentUser models.User + + // Common template variables. + Message string + Error interface{} + ValidationError map[string][]string // form validation errors + Flashes []string + + // Arbitrary controller-specific fields go in V. + V interface{} +} + +// Flash adds a message to flash on the next template render. +func (v *TemplateValues) Flash(msg string, args ...interface{}) { + v.Flashes = append(v.Flashes, fmt.Sprintf(msg, args...)) +} diff --git a/pkg/responses/templates.go b/pkg/responses/templates.go index 56da2d7..30d1903 100644 --- a/pkg/responses/templates.go +++ b/pkg/responses/templates.go @@ -1,93 +1,19 @@ package responses import ( - "errors" "fmt" "html/template" - "io/ioutil" "log" "net/http" - "os" - "strings" - - "git.kirsle.net/apps/gophertype/pkg/bundled" ) -// GetTemplate returns the template file's data, wherever it is. -// Checks the embedded bindata, then the user root on disk, then error. -// If it can be found, returns the contents or error. -func GetFile(path string) ([]byte, error) { - // Check bindata. - if b, err := bundled.Asset(path); err == nil { - return b, nil - } - - // Check the filesystem. TODO - if b, err := ioutil.ReadFile("./pvt-www/" + path); err == nil { - return b, nil - } else { - return []byte{}, err - } -} - -// GetFileExists checks if the file exists but doesn't return its data. -func GetFileExists(path string) bool { - // Check bindata. - if _, err := bundled.AssetInfo(path); err == nil { - return true - } - - // Check the filesystem. TODO - if _, err := os.Stat(path); err == nil { - return true - } - - return false -} - -/* -ResolveFile searches for the existence of a file from a fuzzy URL path. - -`path` is a request path like "/about" - -This function would return e.g. "about.gohtml" as being a file path that is -sure to return data in GetFile(). - -Path finding rules follow expected behavior from dominant web servers: - -- If the exact path is found, return it immediately. -- Try assuming a ".gohtml" or ".md" file extension for the path. -- Try checking if the path is a directory with an "index.gohtml" inside it, etc. -*/ -func ResolveFile(path string) (string, error) { - // Ensure the path doesn't begin with a slash. - path = strings.TrimLeft(path, "/") - - // Try the exact path. - if GetFileExists(path) { - return path, nil - } - - // Try fuzzy file matches. - var tries = []string{ - path + ".gohtml", - path + ".md", - path + "/index.gohtml", - path + "/index.html", - } - for _, try := range tries { - path = strings.TrimLeft(try, "/") - if GetFileExists(path) { - return path, nil - } - } - - return "", errors.New("not found") -} - // RenderTemplate renders a Go HTML template. // The io.Writer can be an http.ResponseWriter. func RenderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, vars interface{}) error { + if vars == nil { + vars = NewTemplateVars(w, r) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") // Look for the built-in template. diff --git a/pkg/routes.go b/pkg/routes.go index bcf935d..3499884 100644 --- a/pkg/routes.go +++ b/pkg/routes.go @@ -5,9 +5,11 @@ import ( "log" "net/http" + "git.kirsle.net/apps/gophertype/pkg/authentication" "git.kirsle.net/apps/gophertype/pkg/controllers" "git.kirsle.net/apps/gophertype/pkg/glue" "git.kirsle.net/apps/gophertype/pkg/middleware" + "git.kirsle.net/apps/gophertype/pkg/session" "github.com/gorilla/mux" ) @@ -15,6 +17,8 @@ import ( func (s *Site) SetupRouter() error { router := mux.NewRouter() + router.Use(session.Middleware) + router.Use(authentication.Middleware) router.Use(middleware.CSRF) for _, route := range glue.GetControllers() { diff --git a/pkg/session/keys.go b/pkg/session/keys.go new file mode 100644 index 0000000..cd5afbd --- /dev/null +++ b/pkg/session/keys.go @@ -0,0 +1,11 @@ +package session + +// Key is a session context key. +type Key int + +// Session key definitions. +const ( + SessionKey Key = iota // The request's cookie session object. + UserKey // The request's user data for logged-in user. + StartTimeKey // The start time of the request. +) diff --git a/pkg/session/sessions.go b/pkg/session/sessions.go new file mode 100644 index 0000000..471d5c4 --- /dev/null +++ b/pkg/session/sessions.go @@ -0,0 +1,86 @@ +package session + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/gorilla/sessions" + "github.com/kirsle/blog/src/types" +) + +// Store holds your cookie store information. +var Store sessions.Store + +// SetSecretKey initializes a session cookie store with the secret key. +func SetSecretKey(keyPairs ...[]byte) { + fmt.Printf("XXXXX SetSecretKey: %+v", keyPairs) + Store = sessions.NewCookieStore(keyPairs...) +} + +// Middleware gets the Gorilla session store and makes it available on the +// Request context. +// +// Middleware is the first custom middleware applied, so it takes the current +// datetime to make available later in the request and stores it on the request +// context. +func Middleware(next http.Handler) http.Handler { + middleware := func(w http.ResponseWriter, r *http.Request) { + // Store the current datetime on the request context. + ctx := context.WithValue(r.Context(), StartTimeKey, time.Now()) + + // Get the Gorilla session and make it available in the request context. + session, _ := Store.Get(r, "session") + ctx = context.WithValue(ctx, SessionKey, session) + + next.ServeHTTP(w, r.WithContext(ctx)) + } + + return http.HandlerFunc(middleware) +} + +// Get returns the current request's session. +func Get(r *http.Request) *sessions.Session { + if r == nil { + panic("Session(*http.Request) with a nil argument!?") + } + + ctx := r.Context() + if session, ok := ctx.Value(types.SessionKey).(*sessions.Session); ok { + return session + } + + // If the session wasn't on the request, it means I broke something. + fmt.Printf( + "ERROR: Session(): didn't find session in request context! Getting it " + + "from the session store instead.", + ) + session, _ := Store.Get(r, "session") + return session +} + +// Flash adds a flashed message to the session for the next template rendering. +func Flash(w http.ResponseWriter, r *http.Request, msg string, args ...interface{}) { + sess := Get(r) + + var flashes []string + if v, ok := sess.Values["flashes"].([]string); ok { + flashes = v + } + + flashes = append(flashes, fmt.Sprintf(msg, args...)) + sess.Values["flashes"] = flashes + sess.Save(r, w) +} + +// GetFlashes returns all the flashes from the session and clears the queue. +func GetFlashes(w http.ResponseWriter, r *http.Request) []string { + sess := Get(r) + if flashes, ok := sess.Values["flashes"].([]string); ok { + sess.Values["flashes"] = []string{} + sess.Save(r, w) + return flashes + } + return []string{} +} diff --git a/pkg/settings/settings.go b/pkg/settings/settings.go new file mode 100644 index 0000000..ce475e3 --- /dev/null +++ b/pkg/settings/settings.go @@ -0,0 +1,69 @@ +package settings + +import ( + "crypto/rand" + "encoding/base64" + + "git.kirsle.net/apps/gophertype/pkg/session" +) + +// Current holds the current app settings. When the app settings have never +// been initialized, this struct holds the default values with a random secret +// key. The config is not saved to DB until you call Save() on it. +var Current = Load() + +// Spec singleton holds the app configuration. +type Spec struct { + // Sets to `true` when the site's initial setup has run and an admin created. + Initialized bool + + // Site information + Title string + Description string + Email string // primary email for notifications + NSFW bool + BaseURL string + + // Blog settings + PostsPerPage int + + // Mail settings + MailEnabled bool + MailSender string + MailHost string + MailPort int + MailUsername string + MailPassword string + + // Security + SecretKey string `json:"-"` +} + +// Load gets or creates the App Settings. +func Load() Spec { + var s = Spec{ + Title: "Untitled Site", + Description: "Just another web blog.", + SecretKey: MakeSecretKey(), + } + + session.SetSecretKey([]byte(s.SecretKey)) + + return s +} + +// Save the settings to DB. +func (s Spec) Save() error { + Current = s + session.SetSecretKey([]byte(s.SecretKey)) + return nil +} + +// MakeSecretKey generates a secret key for signing HTTP cookies. +func MakeSecretKey() string { + keyLength := 32 + + b := make([]byte, keyLength) + rand.Read(b) + return base64.URLEncoding.EncodeToString(b) +} diff --git a/pvt-www/.layout.gohtml b/pvt-www/.layout.gohtml index 8b9dc5d..cde3a2d 100644 --- a/pvt-www/.layout.gohtml +++ b/pvt-www/.layout.gohtml @@ -60,7 +60,7 @@ {{ if .SetupNeeded }}
Your web blog needs to be set up! - Please click here to + Please click here to configure your blog.
{{ end }} @@ -71,9 +71,19 @@ {{ end }} - {{ if .Error }} + {{ if or .Error .ValidationError }}
- Error: {{ .Error }} + Error: {{ or .Error "Validation Error" }}. + + {{ if .ValidationError }} + + {{ end }}
{{ end }} @@ -95,11 +105,11 @@

Control Center

- Logged in as: {{ .CurrentUser.Username }} + Logged in as: {{ .CurrentUser.Name }}