Initial form and model layout, user creating/loading

This commit is contained in:
Noah 2017-11-07 09:01:02 -08:00
parent c69dbfebba
commit 6f330a3e92
16 changed files with 290 additions and 87 deletions

View File

@ -1,55 +1,45 @@
package core package core
import ( import (
"fmt"
"net/http" "net/http"
"github.com/kirsle/blog/core/forms"
"github.com/kirsle/blog/core/models/users" "github.com/kirsle/blog/core/models/users"
) )
// SetupHandler is the initial blog setup route. // SetupHandler is the initial blog setup route.
func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) { func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) {
vars := map[string]interface{}{ vars := &Vars{
"errors": []error{}, Form: forms.Setup{},
} }
if r.Method == "POST" { if r.Method == "POST" {
var errors []error form := forms.Setup{
payload := struct {
Username string
Password string
Confirm string
}{
Username: r.FormValue("username"), Username: r.FormValue("username"),
Password: r.FormValue("password"), Password: r.FormValue("password"),
Confirm: r.FormValue("confirm"), Confirm: r.FormValue("confirm"),
} }
vars.Form = form
// Validate stuff. err := form.Validate()
if len(payload.Username) == 0 { if err != nil {
errors = append(errors, fmt.Errorf("Admin Username is required")) vars.Error = err
} } else {
if len(payload.Password) < 3 { log.Info("Creating admin account %s", form.Username)
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)
user := &users.User{ user := &users.User{
Username: payload.Username, Username: form.Username,
Password: payload.Password, Password: form.Password,
Admin: true,
Name: "Administrator",
} }
err := b.DB.Commit("users/by-name/"+payload.Username, user) err := users.Create(user)
if err != nil { if err != nil {
log.Error("Error: %v", err) log.Error("Error: %v", err)
b.BadRequest(w, r, "DB error when writing user") vars.Error = err
} }
// All set!
b.Redirect(w, "/admin")
return
} }
} }

View File

@ -6,6 +6,7 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/kirsle/blog/core/jsondb" "github.com/kirsle/blog/core/jsondb"
"github.com/kirsle/blog/core/models/users"
"github.com/urfave/negroni" "github.com/urfave/negroni"
) )
@ -33,6 +34,9 @@ func New(documentRoot, userRoot string) *Blog {
DB: jsondb.New(filepath.Join(userRoot, ".private")), DB: jsondb.New(filepath.Join(userRoot, ".private")),
} }
// Initialize all the models.
users.DB = blog.DB
r := mux.NewRouter() r := mux.NewRouter()
blog.r = r blog.r = r
r.HandleFunc("/admin/setup", blog.SetupHandler) r.HandleFunc("/admin/setup", blog.SetupHandler)

9
core/forms/forms.go Normal file
View File

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

32
core/forms/setup.go Normal file
View File

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

View File

@ -124,7 +124,7 @@ func (db *DB) makePath(path string) error {
// list returns the documents under a path with optional recursion. // list returns the documents under a path with optional recursion.
func (db *DB) list(path string, recursive bool) ([]string, error) { func (db *DB) list(path string, recursive bool) ([]string, error) {
root := db.toPath(path) root := filepath.Join(db.Root, path)
var docs []string var docs []string
files, err := ioutil.ReadDir(root) files, err := ioutil.ReadDir(root)

View File

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

View File

@ -1,15 +1,160 @@
package users 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. // User holds information about a user account.
type User struct { type User struct {
models.Base
ID int `json:"id"` ID int `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
Admin bool `json:"admin"`
Name string `json:"name"` 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 { func (u *User) DocumentPath() string {

View File

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

View File

@ -17,8 +17,8 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin
} }
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
err := b.RenderTemplate(w, r, ".errors/404", map[string]interface{}{ err := b.RenderTemplate(w, r, ".errors/404", &Vars{
"message": message[0], Message: message[0],
}) })
if err != nil { if err != nil {
log.Error(err.Error()) 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. // BadRequest sends an HTTP 400 Bad Request.
func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message ...string) { func (b *Blog) BadRequest(w http.ResponseWriter, r *http.Request, message ...string) {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
err := b.RenderTemplate(w, r, ".errors/400", map[string]interface{}{ err := b.RenderTemplate(w, r, ".errors/400", &Vars{
"message": message[0], Message: message[0],
}) })
if err != nil { if err != nil {
log.Error(err.Error()) log.Error(err.Error())

View File

@ -3,26 +3,35 @@ package core
import ( import (
"html/template" "html/template"
"net/http" "net/http"
"github.com/kirsle/blog/core/forms"
) )
// DefaultVars combines template variables with default, globally available vars. // Vars is an interface to implement by the templates to pass their own custom
func (b *Blog) DefaultVars(vars map[string]interface{}) map[string]interface{} { // variables in. It auto-loads global template variables (site name, etc.)
defaults := map[string]interface{}{ // when the template is rendered.
"title": "Untitled Blog", type Vars struct {
} // Global template variables.
if vars == nil { Title string
return defaults
// Common template variables.
Message string
Error error
Form forms.Form
} }
for k, v := range defaults { // LoadDefaults combines template variables with default, globally available vars.
vars[k] = v func (v *Vars) LoadDefaults() {
v.Title = "Untitled Blog"
} }
return vars // TemplateVars is an interface that describes the template variable struct.
type TemplateVars interface {
LoadDefaults()
} }
// RenderTemplate responds with an HTML template. // 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. // Get the layout template.
layout, err := b.ResolvePath(".layout") layout, err := b.ResolvePath(".layout")
if err != nil { if err != nil {
@ -46,7 +55,10 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin
} }
// Inject globally available variables. // 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") w.Header().Set("Content-Type", "text/html; encoding=UTF-8")
err = t.ExecuteTemplate(w, "layout", vars) err = t.ExecuteTemplate(w, "layout", vars)

View File

@ -2,5 +2,5 @@
{{ define "content" }} {{ define "content" }}
<h1>400 Bad Request</h1> <h1>400 Bad Request</h1>
{{ .message }} {{ .Message }}
{{ end }} {{ end }}

View File

@ -1,3 +1,6 @@
{{ define "title" }}Forbidden{{ end }}
{{ define "content" }} {{ define "content" }}
<h1>403 Forbidden</h1> <h1>403 Forbidden</h1>
{{ .Message }}
{{ end }} {{ end }}

View File

@ -2,5 +2,5 @@
{{ define "content" }} {{ define "content" }}
<h1>404 Not Found</h1> <h1>404 Not Found</h1>
{{ .message }} {{ .Message }}
{{ end }} {{ end }}

View File

@ -6,7 +6,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>{{ template "title" or "Untitled" }} - {{ .title }}</title> <title>{{ template "title" or "Untitled" }} - {{ .Title }}</title>
<!-- Bootstrap core CSS --> <!-- Bootstrap core CSS -->
<link rel="stylesheet" href="/css/bootstrap.min.css"> <link rel="stylesheet" href="/css/bootstrap.min.css">
@ -15,7 +15,7 @@
<body> <body>
<nav class="navbar navbar-expand-md fixed-top bluez-navbar"> <nav class="navbar navbar-expand-md fixed-top bluez-navbar">
<a href="#" class="navbar-brand">{{ .title }}</a> <a href="#" class="navbar-brand">{{ .Title }}</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
@ -41,7 +41,7 @@
<div class="bluez-header"> <div class="bluez-header">
<div class="container"> <div class="container">
<h1 class="bluez-title">{{ .title }}</h1> <h1 class="bluez-title">{{ .Title }}</h1>
<p class="lead bluez-description">Just another web blog.</p> <p class="lead bluez-description">Just another web blog.</p>
</div> </div>
</div> </div>
@ -49,6 +49,12 @@
<div class="container mb-5"> <div class="container mb-5">
<div class="row"> <div class="row">
<div class="col-9"> <div class="col-9">
{{ if .Error }}
<div class="alert alert-danger">
<strong>Error:</strong> {{ .Error }}
</div>
{{ end }}
{{ template "content" . }} {{ template "content" . }}
</div> </div>
<div class="col-3"> <div class="col-3">

11
root/admin/index.gohtml Normal file
View File

@ -0,0 +1,11 @@
{{ define "title" }}Admin Center{{ end }}
{{ define "content" }}
<h1>Admin Center</h1>
<ul>
<li><a href="/admin/settings">App Settings</a></li>
<li><a href="/blog/edit">Post Blog Entry</a></li>
<li><a href="/admin/editor">Page Editor</a></li>
<li><a href="/admin/users">User Management</a></li>
</ul>
{{ end }}

View File

@ -2,16 +2,6 @@
{{ define "content" }} {{ define "content" }}
<h1>Initial Setup</h1> <h1>Initial Setup</h1>
{{ if .errors }}
<h2>Please correct the following errors:</h2>
<ul>
{{ range .errors }}
<li>{{ . }}</li>
{{ end }}
</ul>
{{ end }}
<p> <p>
Welcome to your new web blog! To get started, you'll need to create a username Welcome to your new web blog! To get started, you'll need to create a username
and password to be your <strong>admin user</strong>. You can create additional and password to be your <strong>admin user</strong>. You can create additional
@ -26,7 +16,12 @@
<form method="POST" action="/admin/setup"> <form method="POST" action="/admin/setup">
<div class="form-group"> <div class="form-group">
<label for="setup-admin-username">Admin username:</label> <label for="setup-admin-username">Admin username:</label>
<input type="text" name="username" class="form-control" id="setup-admin-username" placeholder="Enter username"> <input type="text"
name="username"
class="form-control"
id="setup-admin-username"
placeholder="Enter username"
value="{{ .Form.Username }}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="setup-admin-password1">Passphrase:</label> <label for="setup-admin-password1">Passphrase:</label>