Initial form and model layout, user creating/loading

pull/4/head
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
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
}
}

View File

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

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

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

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)
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())

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@
<meta charset="utf-8">
<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 -->
<link rel="stylesheet" href="/css/bootstrap.min.css">
@ -15,7 +15,7 @@
<body>
<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">
<span class="navbar-toggler-icon"></span>
</button>
@ -41,7 +41,7 @@
<div class="bluez-header">
<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>
</div>
</div>
@ -49,6 +49,12 @@
<div class="container mb-5">
<div class="row">
<div class="col-9">
{{ if .Error }}
<div class="alert alert-danger">
<strong>Error:</strong> {{ .Error }}
</div>
{{ end }}
{{ template "content" . }}
</div>
<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" }}
<h1>Initial Setup</h1>
{{ if .errors }}
<h2>Please correct the following errors:</h2>
<ul>
{{ range .errors }}
<li>{{ . }}</li>
{{ end }}
</ul>
{{ end }}
<p>
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
@ -26,7 +16,12 @@
<form method="POST" action="/admin/setup">
<div class="form-group">
<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 class="form-group">
<label for="setup-admin-password1">Passphrase:</label>