Sessions, log in and out

master
Noah 2019-11-25 19:55:28 -08:00
parent 9348050b4c
commit 4eef81c07f
22 changed files with 703 additions and 135 deletions

2
go.mod
View File

@ -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
View File

@ -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=

View File

@ -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)
}

View 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)
}

View 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, "/")
},
})
}

View File

@ -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,
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)

View File

@ -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
}

View File

@ -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

View File

@ -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")
}
}

View 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")
}

View 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)
}

View File

@ -11,7 +11,15 @@ import (
// TemplateFuncs available to all templates.
func TemplateFuncs(r *http.Request) template.FuncMap {
return template.FuncMap{
"CSRF": func() template.HTML {
"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(
@ -19,9 +27,19 @@ func TemplateFuncs(r *http.Request) template.FuncMap {
constants.CSRFFormName,
token.Value,
))
},
"TestFunction": func() template.HTML {
return template.HTML("Testing")
},
}
}
// 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")
}
}

View 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...))
}

View File

@ -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.

View File

@ -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
View 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
View 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
View 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)
}

View File

@ -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>

View File

@ -0,0 +1,9 @@
{{ define "title" }}Error{{ end }}
{{ define "content" }}
<h1>An error has occurred.</h1>
{{ if .Message }}
<p>{{ .Message }}</p>
{{ end }}
{{ end }}

View File

@ -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>

View 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 }}