diff --git a/core/admin.go b/core/admin.go index 5191e92..f06a068 100644 --- a/core/admin.go +++ b/core/admin.go @@ -1,55 +1,45 @@ package core import ( - "fmt" "net/http" + "github.com/kirsle/blog/core/forms" "github.com/kirsle/blog/core/models/users" ) // SetupHandler is the initial blog setup route. func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { - vars := map[string]interface{}{ - "errors": []error{}, + vars := &Vars{ + Form: forms.Setup{}, } if r.Method == "POST" { - var errors []error - payload := struct { - Username string - Password string - Confirm string - }{ + form := forms.Setup{ Username: r.FormValue("username"), Password: r.FormValue("password"), Confirm: r.FormValue("confirm"), } - - // Validate stuff. - if len(payload.Username) == 0 { - errors = append(errors, fmt.Errorf("Admin Username is required")) - } - if len(payload.Password) < 3 { - errors = append(errors, fmt.Errorf("Admin Password is too short")) - } - if payload.Password != payload.Confirm { - errors = append(errors, fmt.Errorf("Your passwords do not match")) - } - - vars["errors"] = errors - - // No problems? - if len(errors) == 0 { - log.Info("Creating admin account %s", payload.Username) + vars.Form = form + err := form.Validate() + if err != nil { + vars.Error = err + } else { + log.Info("Creating admin account %s", form.Username) user := &users.User{ - Username: payload.Username, - Password: payload.Password, + Username: form.Username, + Password: form.Password, + Admin: true, + Name: "Administrator", } - err := b.DB.Commit("users/by-name/"+payload.Username, user) + err := users.Create(user) if err != nil { log.Error("Error: %v", err) - b.BadRequest(w, r, "DB error when writing user") + vars.Error = err } + + // All set! + b.Redirect(w, "/admin") + return } } diff --git a/core/app.go b/core/app.go index f32cbb7..d0bd466 100644 --- a/core/app.go +++ b/core/app.go @@ -6,6 +6,7 @@ import ( "github.com/gorilla/mux" "github.com/kirsle/blog/core/jsondb" + "github.com/kirsle/blog/core/models/users" "github.com/urfave/negroni" ) @@ -33,6 +34,9 @@ func New(documentRoot, userRoot string) *Blog { DB: jsondb.New(filepath.Join(userRoot, ".private")), } + // Initialize all the models. + users.DB = blog.DB + r := mux.NewRouter() blog.r = r r.HandleFunc("/admin/setup", blog.SetupHandler) diff --git a/core/forms/forms.go b/core/forms/forms.go new file mode 100644 index 0000000..0c451b7 --- /dev/null +++ b/core/forms/forms.go @@ -0,0 +1,9 @@ +package forms + +import "net/http" + +// Form is an interface for forms that can validate themselves. +type Form interface { + Parse(r *http.Request) + Validate() error +} diff --git a/core/forms/setup.go b/core/forms/setup.go new file mode 100644 index 0000000..87990b8 --- /dev/null +++ b/core/forms/setup.go @@ -0,0 +1,32 @@ +package forms + +import ( + "errors" + "net/http" +) + +// Setup is for the initial blog setup page at /admin/setup. +type Setup struct { + Username string + Password string + Confirm string +} + +// Parse the form. +func (f Setup) Parse(r *http.Request) { + f.Username = r.FormValue("username") + f.Password = r.FormValue("password") + f.Confirm = r.FormValue("confirm") +} + +// Validate the form. +func (f Setup) Validate() error { + if len(f.Username) == 0 { + return errors.New("admin username is required") + } else if len(f.Password) == 0 { + return errors.New("admin password is required") + } else if f.Password != f.Confirm { + return errors.New("your passwords do not match") + } + return nil +} diff --git a/core/jsondb/jsondb.go b/core/jsondb/jsondb.go index 488b7b6..eab1d61 100644 --- a/core/jsondb/jsondb.go +++ b/core/jsondb/jsondb.go @@ -124,7 +124,7 @@ func (db *DB) makePath(path string) error { // list returns the documents under a path with optional recursion. func (db *DB) list(path string, recursive bool) ([]string, error) { - root := db.toPath(path) + root := filepath.Join(db.Root, path) var docs []string files, err := ioutil.ReadDir(root) diff --git a/core/models/base.go b/core/models/base.go deleted file mode 100644 index 15c2d26..0000000 --- a/core/models/base.go +++ /dev/null @@ -1,19 +0,0 @@ -package models - -import "github.com/kirsle/blog/core/jsondb" - -// Model is a generic interface for models. -type Model interface { - UseDB(*jsondb.DB) -} - -// Base is an implementation of the Model interface suitable for including in -// your actual models. -type Base struct { - DB *jsondb.DB -} - -// UseDB stores a reference to your JSON DB for the model to use. -func (b *Base) UseDB(db *jsondb.DB) { - b.DB = db -} diff --git a/core/models/users/users.go b/core/models/users/users.go index 6c0695e..8ddd2ce 100644 --- a/core/models/users/users.go +++ b/core/models/users/users.go @@ -1,15 +1,160 @@ package users -import "github.com/kirsle/blog/core/models" +import ( + "errors" + "fmt" + "strconv" + "strings" + + "github.com/kirsle/blog/core/jsondb" + "golang.org/x/crypto/bcrypt" +) + +// DB is a reference to the parent app's JsonDB object. +var DB *jsondb.DB + +// HashCost is the cost value given to bcrypt to hash passwords. +// TODO: make configurable from main package +var HashCost = 14 // User holds information about a user account. type User struct { - models.Base ID int `json:"id"` Username string `json:"username"` Password string `json:"password"` + Admin bool `json:"admin"` Name string `json:"name"` - Role string `json:"role"` + Email string `json:"email"` +} + +// ByName model maps usernames to their IDs. +type ByName struct { + ID int `json:"id"` +} + +// Create a new user. +func Create(u *User) error { + // Sanity checks. + u.Username = Normalize(u.Username) + if len(u.Username) == 0 { + return errors.New("username is required") + } else if len(u.Password) == 0 { + return errors.New("password is required") + } + + // Make sure the username is available. + if UsernameExists(u.Username) { + return errors.New("that username already exists") + } + + // Assign the next ID. + u.ID = nextID() + + // Hash the password. + u.SetPassword(u.Password) + + // TODO: check existing + + return u.Save() +} + +// SetPassword sets a user's password by bcrypt hashing it. After this function, +// u.Password will contain the bcrypt hash. +func (u *User) SetPassword(password string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(password), HashCost) + if err != nil { + return err + } + + u.Password = string(hash) + return nil +} + +// UsernameExists checks if a username is taken. +func UsernameExists(username string) bool { + username = Normalize(username) + return DB.Exists("users/by-name/" + username) +} + +// LoadUsername loads a user by username. +func LoadUsername(username string) (*User, error) { + username = Normalize(username) + u := &User{} + + // Look up the user ID by name. + name := ByName{} + err := DB.Get("users/by-name/"+username, &name) + if err != nil { + return u, fmt.Errorf("failed to look up user ID for username %s: %v", username, err) + } + + // And load that user. + return Load(name.ID) +} + +// Load a user by their ID number. +func Load(id int) (*User, error) { + u := &User{} + err := DB.Get(fmt.Sprintf("users/by-id/%d", id), &u) + return u, err +} + +// Save the user. +func (u *User) Save() error { + // Sanity check that we have an ID. + if u.ID == 0 { + return errors.New("can't save: user does not have an ID!") + } + + // Save the main DB file. + err := DB.Commit(u.key(), u) + if err != nil { + return err + } + + // The username to ID mapping. + err = DB.Commit(u.nameKey(), ByName{u.ID}) + if err != nil { + return err + } + + return nil +} + +// Get the next user ID number. +func nextID() int { + // Highest ID seen so far. + var highest int + + users, err := DB.List("users/by-id") + if err != nil { + panic(err) + } + + for _, doc := range users { + fields := strings.Split(doc, "/") + id, err := strconv.Atoi(fields[len(fields)-1]) + if err != nil { + continue + } + + if id > highest { + highest = id + } + } + + // Return the highest +1 + return highest + 1 +} + +// DB key for users by ID number. +func (u *User) key() string { + return fmt.Sprintf("users/by-id/%d", u.ID) +} + +// DB key for users by username. +func (u *User) nameKey() string { + return "users/by-name/" + u.Username } func (u *User) DocumentPath() string { diff --git a/core/models/users/utils.go b/core/models/users/utils.go new file mode 100644 index 0000000..422f157 --- /dev/null +++ b/core/models/users/utils.go @@ -0,0 +1,15 @@ +package users + +import ( + "regexp" + "strings" +) + +// Normalize lowercases and safens a username. +func Normalize(username string) string { + username = strings.ToLower(username) + + // Strip special characters. + re := regexp.MustCompile(`[./\\]+`) + return re.ReplaceAllString(username, "") +} diff --git a/core/responses.go b/core/responses.go index 5a8287c..ca748ed 100644 --- a/core/responses.go +++ b/core/responses.go @@ -17,8 +17,8 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin } w.WriteHeader(http.StatusNotFound) - err := b.RenderTemplate(w, r, ".errors/404", map[string]interface{}{ - "message": message[0], + err := b.RenderTemplate(w, r, ".errors/404", &Vars{ + Message: message[0], }) if err != nil { log.Error(err.Error()) @@ -39,8 +39,8 @@ func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...stri // BadRequest sends an HTTP 400 Bad Request. func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message ...string) { w.WriteHeader(http.StatusBadRequest) - err := b.RenderTemplate(w, r, ".errors/400", map[string]interface{}{ - "message": message[0], + err := b.RenderTemplate(w, r, ".errors/400", &Vars{ + Message: message[0], }) if err != nil { log.Error(err.Error()) diff --git a/core/templates.go b/core/templates.go index 9b00c4f..59b8bd0 100644 --- a/core/templates.go +++ b/core/templates.go @@ -3,26 +3,35 @@ package core import ( "html/template" "net/http" + + "github.com/kirsle/blog/core/forms" ) -// DefaultVars combines template variables with default, globally available vars. -func (b *Blog) DefaultVars(vars map[string]interface{}) map[string]interface{} { - defaults := map[string]interface{}{ - "title": "Untitled Blog", - } - if vars == nil { - return defaults - } +// Vars is an interface to implement by the templates to pass their own custom +// variables in. It auto-loads global template variables (site name, etc.) +// when the template is rendered. +type Vars struct { + // Global template variables. + Title string - for k, v := range defaults { - vars[k] = v - } + // Common template variables. + Message string + Error error + Form forms.Form +} - return vars +// LoadDefaults combines template variables with default, globally available vars. +func (v *Vars) LoadDefaults() { + v.Title = "Untitled Blog" +} + +// TemplateVars is an interface that describes the template variable struct. +type TemplateVars interface { + LoadDefaults() } // RenderTemplate responds with an HTML template. -func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path string, vars map[string]interface{}) error { +func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path string, vars TemplateVars) error { // Get the layout template. layout, err := b.ResolvePath(".layout") if err != nil { @@ -46,7 +55,10 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin } // Inject globally available variables. - vars = b.DefaultVars(vars) + if vars == nil { + vars = &Vars{} + } + vars.LoadDefaults() w.Header().Set("Content-Type", "text/html; encoding=UTF-8") err = t.ExecuteTemplate(w, "layout", vars) diff --git a/root/.errors/400.gohtml b/root/.errors/400.gohtml index 6af6a61..25842a0 100644 --- a/root/.errors/400.gohtml +++ b/root/.errors/400.gohtml @@ -2,5 +2,5 @@ {{ define "content" }}