diff --git a/go.mod b/go.mod index 9508a4a..dfe22b5 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,7 @@ require ( github.com/gorilla/mux v1.7.3 github.com/jinzhu/gorm v1.9.11 github.com/kirsle/blog v0.0.0-20191022175051-d78814b9c99b + github.com/satori/go.uuid v1.2.0 github.com/urfave/negroni v1.0.0 + golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443 ) diff --git a/go.sum b/go.sum index 22903f6..88ff864 100644 --- a/go.sum +++ b/go.sum @@ -35,15 +35,19 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= 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/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= @@ -88,6 +92,8 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0= github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU= diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go new file mode 100644 index 0000000..77ce3d9 --- /dev/null +++ b/pkg/constants/constants.go @@ -0,0 +1,8 @@ +package constants + +// Misc constants. +const ( + // Password values + PasswordMinLength = 8 + BcryptCost = 14 +) diff --git a/pkg/constants/csrf.go b/pkg/constants/csrf.go new file mode 100644 index 0000000..98f51db --- /dev/null +++ b/pkg/constants/csrf.go @@ -0,0 +1,7 @@ +package constants + +// CSRF protection constants. +const ( + CSRFCookieName = "csrf_token" + CSRFFormName = "_csrf" +) diff --git a/pkg/controllers/index.go b/pkg/controllers/index.go index e64cf00..fd4fc45 100644 --- a/pkg/controllers/index.go +++ b/pkg/controllers/index.go @@ -9,16 +9,6 @@ import ( ) func init() { - glue.Register(glue.Endpoint{ - Path: "/about", - Middleware: []mux.MiddlewareFunc{ - middleware.ExampleMiddleware, - }, - Handler: func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("About Site")) - }, - }) - glue.Register(glue.Endpoint{ Path: "/admin", Middleware: []mux.MiddlewareFunc{ diff --git a/pkg/controllers/initial_setup.go b/pkg/controllers/initial_setup.go index d7174c4..13c4d1e 100644 --- a/pkg/controllers/initial_setup.go +++ b/pkg/controllers/initial_setup.go @@ -1,22 +1,73 @@ package controllers import ( + "errors" + "fmt" "net/http" + "git.kirsle.net/apps/gophertype/pkg/constants" "git.kirsle.net/apps/gophertype/pkg/glue" "git.kirsle.net/apps/gophertype/pkg/middleware" + "git.kirsle.net/apps/gophertype/pkg/models" "git.kirsle.net/apps/gophertype/pkg/responses" "github.com/gorilla/mux" ) func init() { glue.Register(glue.Endpoint{ - Path: "/admin/setup", + Path: "/admin/setup", + Methods: []string{"GET", "POST"}, Middleware: []mux.MiddlewareFunc{ middleware.ExampleMiddleware, }, Handler: func(w http.ResponseWriter, r *http.Request) { - responses.RenderTemplate(w, "_builtin/initial_setup.gohtml", nil) + // See if we already have an admin account. + if _, err := models.FirstAdmin(); err == nil { + responses.Panic(w, http.StatusForbidden, "This site is already initialized.") + return + } + + // Template variables. + v := map[string]interface{}{} + + // POST handler: create the admin account. + if r.Method == http.MethodPost { + var ( + username = r.FormValue("username") + displayName = r.FormValue("name") + password = r.FormValue("password") + password2 = r.FormValue("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) + } + if password != password2 { + v["Error"] = errors.New("your passwords don't match") + } else { + admin := models.User{ + Username: username, + Name: displayName, + IsAdmin: true, + } + admin.SetPassword(password) + + if err := models.CreateUser(admin); err != nil { + v["Error"] = err + } else { + // Admin created! Make the default config. + cfg := models.GetSettings() + cfg.Save() + + w.Write([]byte("Success")) + return + } + } + } + + responses.RenderTemplate(w, r, "_builtin/initial_setup.gohtml", v) }, }) diff --git a/pkg/controllers/static_files.go b/pkg/controllers/static_files.go index cfe80e1..65c06f4 100644 --- a/pkg/controllers/static_files.go +++ b/pkg/controllers/static_files.go @@ -18,7 +18,7 @@ func CatchAllHandler(w http.ResponseWriter, r *http.Request) { // Resolve the target path. filepath, err := responses.ResolveFile(path) if err != nil { - responses.Panic(w, http.StatusNotFound, "ResolveFile: "+err.Error()) + responses.NotFound(w, r) return } @@ -31,7 +31,7 @@ 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, filepath, nil) + responses.RenderTemplate(w, r, filepath, nil) return } diff --git a/pkg/middleware/csrf.go b/pkg/middleware/csrf.go new file mode 100644 index 0000000..3f04a92 --- /dev/null +++ b/pkg/middleware/csrf.go @@ -0,0 +1,48 @@ +package middleware + +import ( + "net/http" + "time" + + "git.kirsle.net/apps/gophertype/pkg/constants" + "git.kirsle.net/apps/gophertype/pkg/responses" + uuid "github.com/satori/go.uuid" +) + +// CSRF prevents Cross-Site Request Forgery. +// All "POST" requests are required to have an "_csrf" variable passed in which +// matches the "csrf_token" HTTP cookie with their request. +func CSRF(next http.Handler) http.Handler { + middleware := func(w http.ResponseWriter, r *http.Request) { + // All requests: verify they have a CSRF cookie, create one if not. + var token string + cookie, err := r.Cookie(constants.CSRFCookieName) + if err == nil { + token = cookie.Value + } + + // Generate a token cookie if not found. + if len(token) < 8 || err != nil { + token = uuid.NewV4().String() + cookie = &http.Cookie{ + Name: constants.CSRFCookieName, + Value: token, + Expires: time.Now().Add(24 * time.Hour), + } + http.SetCookie(w, cookie) + } + + // POST requests: verify token from form parameter. + if r.Method == http.MethodPost { + compare := r.FormValue(constants.CSRFFormName) + if compare != token { + responses.Panic(w, http.StatusForbidden, "CSRF token failure.") + return + } + } + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(middleware) +} diff --git a/pkg/models/app_settings.go b/pkg/models/app_settings.go new file mode 100644 index 0000000..7e53edb --- /dev/null +++ b/pkg/models/app_settings.go @@ -0,0 +1,72 @@ +package models + +import ( + "crypto/rand" + "encoding/base64" + + "github.com/jinzhu/gorm" +) + +// AppSetting singleton holds the app configuration. +type AppSetting struct { + gorm.Model + + // 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:"-"` +} + +// GetSettings gets or creates the App Settings. +func GetSettings() AppSetting { + var s AppSetting + r := DB.First(&s) + if r.Error != nil { + s = AppSetting{ + Title: "Untitled Site", + Description: "Just another web blog.", + SecretKey: MakeSecretKey(), + } + } + + if s.SecretKey == "" { + s.SecretKey = MakeSecretKey() + } + + return s +} + +// Save the settings to DB. +func (s AppSetting) Save() error { + if DB.NewRecord(s) { + r := DB.Create(&s) + return r.Error + } + r := DB.Save(&s) + return r.Error +} + +// 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/pkg/models/models.go b/pkg/models/models.go index 72e3d9b..11cbaf0 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -8,5 +8,6 @@ var DB *gorm.DB // UseDB registers a database driver. func UseDB(db *gorm.DB) { DB = db + DB.AutoMigrate(&AppSetting{}) DB.AutoMigrate(&User{}) } diff --git a/pkg/models/users.go b/pkg/models/users.go index 0ecded1..a903aa4 100644 --- a/pkg/models/users.go +++ b/pkg/models/users.go @@ -1,11 +1,67 @@ package models +import ( + "errors" + "fmt" + "strings" + + "git.kirsle.net/apps/gophertype/pkg/constants" + "github.com/jinzhu/gorm" + "golang.org/x/crypto/bcrypt" +) + // User account for the site. type User struct { - ID int `json:"id"` - Username string `json:"username" gorm:"unique"` - Password string `json:"-"` - IsAdmin bool `json:"isAdmin"` - Name string `json:"name"` - Email string `json:"email"` + gorm.Model + Username string `json:"username" gorm:"unique_index"` + 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)) + + // Defaults + if len(u.Name) == 0 { + u.Name = u.Username + } + + if len(u.Username) == 0 { + return errors.New("username is required") + } + return nil +} + +// SetPassword stores the hashed password for a user. +func (u *User) SetPassword(password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), constants.BcryptCost) + if err != nil { + return fmt.Errorf("SetPassword: %s", err) + } + + u.HashedPassword = string(hash) + fmt.Printf("Set hashed password: %s", u.HashedPassword) + return nil +} + +// FirstAdmin returns the admin user with the lowest ID number. +func FirstAdmin() (User, error) { + var user User + r := DB.First(&user, "is_admin", true) + return user, r.Error +} + +// CreateUser adds a new user to the database. +func CreateUser(u User) error { + if err := u.Validate(); err != nil { + return err + } + + r := DB.Create(&u) + return r.Error } diff --git a/pkg/responses/errors.go b/pkg/responses/errors.go index 9805600..3de3773 100644 --- a/pkg/responses/errors.go +++ b/pkg/responses/errors.go @@ -1,9 +1,33 @@ package responses -import "net/http" +import ( + "log" + "net/http" +) // Panic gives a simple error with no template or anything fancy. func Panic(w http.ResponseWriter, code int, message string) { w.WriteHeader(code) w.Write([]byte(message)) } + +// Error returns an error page. +func Error(w http.ResponseWriter, r *http.Request, code int, message string) { + v := map[string]interface{}{ + "Message": message, + } + w.WriteHeader(code) + + if err := RenderTemplate(w, r, "_builtin/errors/generic.gohtml", v); err != nil { + log.Printf("responses.Error: failed to render a pretty error template: %s", err) + w.Write([]byte(message)) + } +} + +// NotFound returns an HTML 404 page. +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")) + } +} diff --git a/pkg/responses/template_functions.go b/pkg/responses/template_functions.go new file mode 100644 index 0000000..e827155 --- /dev/null +++ b/pkg/responses/template_functions.go @@ -0,0 +1,27 @@ +package responses + +import ( + "fmt" + "html/template" + "net/http" + + "git.kirsle.net/apps/gophertype/pkg/constants" +) + +// 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") + }, + } +} diff --git a/pkg/responses/templates.go b/pkg/responses/templates.go index f8e9dea..56da2d7 100644 --- a/pkg/responses/templates.go +++ b/pkg/responses/templates.go @@ -87,21 +87,22 @@ func ResolveFile(path string) (string, error) { // RenderTemplate renders a Go HTML template. // The io.Writer can be an http.ResponseWriter. -func RenderTemplate(w http.ResponseWriter, tmpl string, vars interface{}) error { +func RenderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, vars interface{}) error { w.Header().Set("Content-Type", "text/html; charset=utf-8") // Look for the built-in template. if b, err := GetFile(tmpl); err == nil { - t, err := template.New(tmpl).Parse(string(b)) + t, err := template.New(tmpl).Funcs(TemplateFuncs(r)).Parse(string(b)) if err != nil { - return fmt.Errorf("bundled template '%s': %s", tmpl, err) + log.Printf("bundled template '%s': %s", tmpl, err) + return err } // We found the template. Can we find the layout html? if layout, err := GetFile(".layout.gohtml"); err == nil { _, err := t.New("layout").Parse(string(layout)) if err != nil { - fmt.Errorf("RenderTemplate(.layout.gohtml): %s", err) + log.Printf("RenderTemplate(.layout.gohtml): %s", err) } } else { log.Printf("RenderTemplate: .layout.gohtml not found to wrap %s", tmpl) diff --git a/pkg/routes.go b/pkg/routes.go index 3ee330e..bcf935d 100644 --- a/pkg/routes.go +++ b/pkg/routes.go @@ -7,6 +7,7 @@ import ( "git.kirsle.net/apps/gophertype/pkg/controllers" "git.kirsle.net/apps/gophertype/pkg/glue" + "git.kirsle.net/apps/gophertype/pkg/middleware" "github.com/gorilla/mux" ) @@ -14,6 +15,8 @@ import ( func (s *Site) SetupRouter() error { router := mux.NewRouter() + router.Use(middleware.CSRF) + for _, route := range glue.GetControllers() { log.Printf("Register: %+v", route) if len(route.Methods) == 0 { diff --git a/pvt-www/_builtin/error.gohtml b/pvt-www/_builtin/error.gohtml new file mode 100644 index 0000000..fa2bfab --- /dev/null +++ b/pvt-www/_builtin/error.gohtml @@ -0,0 +1,11 @@ +{{ define "title" }}An error has occurred{{ end }} +{{ define "content" }} +

An error has occurred

+ +{{ if .Message }} + +{{ end }} + +{{ end }} diff --git a/pvt-www/_builtin/errors/404.gohtml b/pvt-www/_builtin/errors/404.gohtml new file mode 100644 index 0000000..30af307 --- /dev/null +++ b/pvt-www/_builtin/errors/404.gohtml @@ -0,0 +1,6 @@ +{{ define "title" }}Not Found{{ end }} +{{ define "content" }} +

Not Found

+ +The page you were looking for was not found. +{{ end }} diff --git a/pvt-www/_builtin/initial_setup.gohtml b/pvt-www/_builtin/initial_setup.gohtml index 3b864e9..89dc353 100644 --- a/pvt-www/_builtin/initial_setup.gohtml +++ b/pvt-www/_builtin/initial_setup.gohtml @@ -1,5 +1,66 @@ {{ define "title" }}Initial Setup{{ end }} - {{ define "content" }}

Initial Setup

+ +{{ if .Error }} + +{{ end }} + +

+ Welcome to Gophertype! Fill out the basic configuration below to set up the app. +

+ +
+ {{ CSRF }} +
+
+ Create Administrator Login +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+
{{ end }} diff --git a/pvt-www/about.md b/pvt-www/about.md new file mode 100644 index 0000000..4f2b528 --- /dev/null +++ b/pvt-www/about.md @@ -0,0 +1,10 @@ +# About Blog + +This is a simple web blog and content management system written in Go. + +## Features + +* Web blog + * Draft, Private Posts +* Page editor + * You can edit any page from the front-end.