Sessions, log in and out
This commit is contained in:
parent
9348050b4c
commit
4eef81c07f
2
go.mod
2
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
|
||||
|
|
4
go.sum
4
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=
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
87
pkg/authentication/login.go
Normal file
87
pkg/authentication/login.go
Normal file
|
@ -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)
|
||||
}
|
66
pkg/controllers/authentication.go
Normal file
66
pkg/controllers/authentication.go
Normal file
|
@ -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, "/")
|
||||
},
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
82
pkg/responses/filesystem.go
Normal file
82
pkg/responses/filesystem.go
Normal file
|
@ -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")
|
||||
}
|
9
pkg/responses/redirect.go
Normal file
9
pkg/responses/redirect.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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(
|
||||
`<input type="hidden" name="%s" value="%s">`,
|
||||
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(
|
||||
`<input type="hidden" name="%s" value="%s">`,
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
77
pkg/responses/template_vars.go
Normal file
77
pkg/responses/template_vars.go
Normal file
|
@ -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...))
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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() {
|
||||
|
|
11
pkg/session/keys.go
Normal file
11
pkg/session/keys.go
Normal file
|
@ -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.
|
||||
)
|
86
pkg/session/sessions.go
Normal file
86
pkg/session/sessions.go
Normal file
|
@ -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{}
|
||||
}
|
69
pkg/settings/settings.go
Normal file
69
pkg/settings/settings.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -60,7 +60,7 @@
|
|||
{{ if .SetupNeeded }}
|
||||
<div class="alert alert-success">
|
||||
Your web blog needs to be set up!
|
||||
Please <a href="/initial-setup">click here</a> to
|
||||
Please <a href="/admin/setup">click here</a> to
|
||||
configure your blog.
|
||||
</div>
|
||||
{{ end }}
|
||||
|
@ -71,9 +71,19 @@
|
|||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if .Error }}
|
||||
{{ if or .Error .ValidationError }}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Error:</strong> {{ .Error }}
|
||||
<strong>Error:</strong> {{ or .Error "Validation Error" }}.
|
||||
|
||||
{{ if .ValidationError }}
|
||||
<ul>
|
||||
{{ range $key, $val := .ValidationError }}
|
||||
{{ range $bullet := $val }}
|
||||
<li>{{ $key }}: {{ $bullet }}</li>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
|
@ -95,11 +105,11 @@
|
|||
<h4 class="cart-title">Control Center</h4>
|
||||
|
||||
<p>
|
||||
Logged in as: <a href="/account">{{ .CurrentUser.Username }}</a>
|
||||
Logged in as: <a href="/account">{{ .CurrentUser.Name }}</a>
|
||||
</p>
|
||||
|
||||
<ul class="list-unstyled">
|
||||
{{ if .CurrentUser.Admin }}
|
||||
{{ if .CurrentUser.IsAdmin }}
|
||||
<li class="list-item"><a href="/admin">Admin Center</a></li>
|
||||
{{ end }}
|
||||
<li class="list-item"><a href="/logout">Log out</a></li>
|
||||
|
|
9
pvt-www/_builtin/errors/generic.gohtml
Normal file
9
pvt-www/_builtin/errors/generic.gohtml
Normal file
|
@ -0,0 +1,9 @@
|
|||
{{ define "title" }}Error{{ end }}
|
||||
{{ define "content" }}
|
||||
<h1>An error has occurred.</h1>
|
||||
|
||||
{{ if .Message }}
|
||||
<p>{{ .Message }}</p>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
|
@ -2,12 +2,6 @@
|
|||
{{ define "content" }}
|
||||
<h1>Initial Setup</h1>
|
||||
|
||||
{{ if .Error }}
|
||||
<div class="banner banner-danger mb-4">
|
||||
<strong>Error:</strong> {{ .Error }}
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<p>
|
||||
Welcome to Gophertype! Fill out the basic configuration below to set up the app.
|
||||
</p>
|
||||
|
@ -21,18 +15,20 @@
|
|||
<div class="card-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-6">
|
||||
<label for="username">Username<span class="text-danger">*</span>:</label>
|
||||
<input type="text" class="form-control"
|
||||
name="username"
|
||||
id="username"
|
||||
placeholder="Admin"
|
||||
<label for="email">Email<span class="text-danger">*</span>:</label>
|
||||
<input type="email" class="form-control"
|
||||
name="email"
|
||||
id="email"
|
||||
value="{{ FormValue "email" }}"
|
||||
placeholder="name@domain.com"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label for="username">Display Name:</label>
|
||||
<label for="name">Display Name:</label>
|
||||
<input type="text" class="form-control"
|
||||
name="name"
|
||||
id="name"
|
||||
value="{{ FormValue "name" }}"
|
||||
placeholder="Soandso">
|
||||
</div>
|
||||
</div>
|
||||
|
@ -42,6 +38,7 @@
|
|||
<label for="password">Password<span class="text-danger">*</span>:</label>
|
||||
<input type="password" class="form-control"
|
||||
name="password"
|
||||
value="{{ FormValue "password" }}"
|
||||
id="password"
|
||||
required>
|
||||
</div>
|
||||
|
@ -49,6 +46,7 @@
|
|||
<label for="confirm">Confirm<span class="text-danger">*</span>:</label>
|
||||
<input type="password" class="form-control"
|
||||
name="password2"
|
||||
value="{{ FormValue "password" }}"
|
||||
id="confirm"
|
||||
required>
|
||||
</div>
|
||||
|
|
34
pvt-www/_builtin/users/login.gohtml
Normal file
34
pvt-www/_builtin/users/login.gohtml
Normal file
|
@ -0,0 +1,34 @@
|
|||
{{ define "title" }}Sign in{{ end }}
|
||||
{{ define "content" }}
|
||||
<h1>Sign in</h1>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
{{ CSRF }}
|
||||
<div class="form-row">
|
||||
<div class="form-group col-md-6">
|
||||
<label for="email">Email<span class="text-danger">*</span>:</label>
|
||||
<input type="email" class="form-control"
|
||||
name="email"
|
||||
id="email"
|
||||
value="{{ FormValue "email" }}"
|
||||
placeholder="name@domain.com"
|
||||
required>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" class="form-control"
|
||||
name="password"
|
||||
id="password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="form-group col">
|
||||
<p>
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{{ end }}
|
Loading…
Reference in New Issue
Block a user