Add JsonDB and initial Admin Setup POST handler

This commit is contained in:
Noah 2017-11-03 09:52:40 -07:00
parent 456cad7a50
commit c69dbfebba
11 changed files with 358 additions and 9 deletions

View File

@ -21,7 +21,7 @@ build:
# `make run` to run it in debug mode. # `make run` to run it in debug mode.
.PHONY: run .PHONY: run
run: run:
./go-reload main.go -debug ./go-reload main.go -debug root
# `make test` to run unit tests. # `make test` to run unit tests.
.PHONY: test .PHONY: test

View File

@ -1,10 +1,57 @@
package core package core
import ( import (
"fmt"
"net/http" "net/http"
"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) {
b.RenderTemplate(w, r, "admin/setup", nil) vars := map[string]interface{}{
"errors": []error{},
}
if r.Method == "POST" {
var errors []error
payload := struct {
Username string
Password string
Confirm string
}{
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)
user := &users.User{
Username: payload.Username,
Password: payload.Password,
}
err := b.DB.Commit("users/by-name/"+payload.Username, user)
if err != nil {
log.Error("Error: %v", err)
b.BadRequest(w, r, "DB error when writing user")
}
}
}
b.RenderTemplate(w, r, "admin/setup", vars)
} }

View File

@ -2,8 +2,10 @@ package core
import ( import (
"net/http" "net/http"
"path/filepath"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/kirsle/blog/core/jsondb"
"github.com/urfave/negroni" "github.com/urfave/negroni"
) )
@ -16,6 +18,8 @@ type Blog struct {
DocumentRoot string DocumentRoot string
UserRoot string UserRoot string
DB *jsondb.DB
// Web app objects. // Web app objects.
n *negroni.Negroni // Negroni middleware manager n *negroni.Negroni // Negroni middleware manager
r *mux.Router // Router r *mux.Router // Router
@ -26,7 +30,9 @@ func New(documentRoot, userRoot string) *Blog {
blog := &Blog{ blog := &Blog{
DocumentRoot: documentRoot, DocumentRoot: documentRoot,
UserRoot: userRoot, UserRoot: userRoot,
DB: jsondb.New(filepath.Join(userRoot, ".private")),
} }
r := mux.NewRouter() r := mux.NewRouter()
blog.r = r blog.r = r
r.HandleFunc("/admin/setup", blog.SetupHandler) r.HandleFunc("/admin/setup", blog.SetupHandler)

187
core/jsondb/jsondb.go Normal file
View File

@ -0,0 +1,187 @@
// Package jsondb implements a flat file JSON database engine.
package jsondb
import (
"encoding/json"
"errors"
"io/ioutil"
"os"
"path/filepath"
"strings"
)
// DB is the database manager.
type DB struct {
Root string // The root directory of the database
// Use Redis to cache filesystem reads of the database.
EnableRedis bool
RedisURL string
}
// Error codes returned.
var (
ErrNotFound = errors.New("document not found")
)
// New initializes the JSON database.
func New(root string) *DB {
return &DB{
Root: root,
}
}
// Get a document by path and load it into the object `v`.
func (db *DB) Get(document string, v interface{}) error {
log.Debug("GET %s", document)
if !db.Exists(document) {
return ErrNotFound
}
// Get the file path and stats.
path := db.toPath(document)
_, err := os.Stat(path) // TODO: mtime for caching
if err != nil {
return err
}
// Read the JSON.
err = db.readJSON(path, &v)
if err != nil {
return err
}
return nil
}
// Commit writes a JSON object to the database.
func (db *DB) Commit(document string, v interface{}) error {
log.Debug("COMMIT %s", document)
path := db.toPath(document)
// Ensure the directory tree is ready.
db.makePath(path)
// Write the document.
err := db.writeJSON(path, v)
if err != nil {
return err
}
return nil
}
// Delete removes a JSON document from the database.
func (db *DB) Delete(document string) error {
log.Debug("DELETE %s", document)
path := db.toPath(document)
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Warn("Delete document %s: did not exist")
return nil
}
return os.Remove(path)
}
// Exists tells you whether a document exists in the database.
func (db *DB) Exists(document string) bool {
if _, err := os.Stat(db.toPath(document)); os.IsNotExist(err) {
return false
}
return true
}
// List all the documents at the path given.
func (db *DB) List(path string) ([]string, error) {
return db.list(path, false)
}
// ListAll recursively lists the documents at the path prefix given.
func (db *DB) ListAll(path string) ([]string, error) {
return db.list(path, true)
}
// makePath ensures all the directory components in a document path exist.
// path: the filesystem path like from toPath().
func (db *DB) makePath(path string) error {
parts := strings.Split(path, string(filepath.Separator))
log.Debug("%v", parts)
parts = parts[:len(parts)-1] // pop off the filename
log.Debug("%v", parts)
directory := filepath.Join(parts...)
log.Debug("Ensure exists: %s (from orig path %s)", directory, path)
if _, err := os.Stat(directory); err != nil {
log.Debug("Create directory: %s", directory)
err = os.MkdirAll(directory, 0755)
return err
}
return nil
}
// list returns the documents under a path with optional recursion.
func (db *DB) list(path string, recursive bool) ([]string, error) {
root := db.toPath(path)
var docs []string
files, err := ioutil.ReadDir(root)
if err != nil {
return docs, err
}
for _, file := range files {
filePath := filepath.Join(root, file.Name())
dbPath := filepath.Join(path, file.Name())
if file.IsDir() && recursive {
subfiles, err := db.list(dbPath, recursive)
if err != nil {
return docs, err
}
docs = append(docs, subfiles...)
continue
}
if strings.HasSuffix(filePath, ".json") {
name := strings.TrimSuffix(filePath, ".json")
docs = append(docs, name)
}
}
return docs, nil
}
// readJSON reads a JSON file from disk.
func (db *DB) readJSON(path string, v interface{}) error {
fh, err := os.Open(path)
if err != nil {
return err
}
defer fh.Close()
decoder := json.NewDecoder(fh)
err = decoder.Decode(&v)
return err
}
// writeJSON writes a JSON document to disk.
func (db *DB) writeJSON(path string, v interface{}) error {
fh, err := os.Create(path)
if err != nil {
return err
}
defer fh.Close()
encoder := json.NewEncoder(fh)
encoder.SetIndent("", "\t")
encoder.Encode(v)
return nil
}
// toPath translates a document name into a filesystem path.
func (db *DB) toPath(document string) string {
return filepath.Join(db.Root, document+".json")
}

28
core/jsondb/log.go Normal file
View File

@ -0,0 +1,28 @@
// Package jsondb implements a flat file JSON database engine.
package jsondb
import (
"github.com/kirsle/golog"
)
var log *golog.Logger
func init() {
log = golog.GetLogger("jsondb")
log.Configure(&golog.Config{
Level: golog.InfoLevel,
Colors: golog.ExtendedColor,
Theme: golog.DarkTheme,
})
}
// SetDebug turns on debug logging.
func SetDebug(debug bool) {
if debug {
log.Config.Level = golog.DebugLevel
log.Debug("JsonDB Debug log enabled.")
} else {
log.Config.Level = golog.InfoLevel
log.Info("JsonDB Debug log disabled.")
}
}

19
core/models/base.go Normal file
View File

@ -0,0 +1,19 @@
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

@ -0,0 +1,17 @@
package users
import "github.com/kirsle/blog/core/models"
// User holds information about a user account.
type User struct {
models.Base
ID int `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Name string `json:"name"`
Role string `json:"role"`
}
func (u *User) DocumentPath() string {
return "users/by-id/%s"
}

View File

@ -22,15 +22,28 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin
}) })
if err != nil { if err != nil {
log.Error(err.Error()) log.Error(err.Error())
http.NotFound(w, r) w.Write([]byte("Unrecoverable template error for NotFound()"))
} }
} }
// Forbidden sends an HTTP 400 Forbidden response. // Forbidden sends an HTTP 403 Forbidden response.
func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) { func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) {
w.WriteHeader(http.StatusForbidden) w.WriteHeader(http.StatusForbidden)
err := b.RenderTemplate(w, r, ".errors/403", nil) err := b.RenderTemplate(w, r, ".errors/403", nil)
if err != nil { if err != nil {
http.NotFound(w, r) log.Error(err.Error())
w.Write([]byte("Unrecoverable template error for Forbidden()"))
}
}
// 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],
})
if err != nil {
log.Error(err.Error())
w.Write([]byte("Unrecoverable template error for BadRequest()"))
} }
} }

15
main.go
View File

@ -6,8 +6,11 @@ package main
import ( import (
"flag" "flag"
"fmt"
"os"
"github.com/kirsle/blog/core" "github.com/kirsle/blog/core"
"github.com/kirsle/blog/core/jsondb"
) )
// Build-time config constants. // Build-time config constants.
@ -32,8 +35,16 @@ func init() {
func main() { func main() {
flag.Parse() flag.Parse()
userRoot := flag.Arg(0)
if userRoot == "" {
fmt.Printf("Need user root\n")
os.Exit(1)
}
app := core.New(DocumentRoot, "") app := core.New(DocumentRoot, userRoot)
app.Debug = fDebug if fDebug {
app.Debug = true
jsondb.SetDebug(true)
}
app.ListenAndServe(fAddress) app.ListenAndServe(fAddress)
} }

6
root/.errors/400.gohtml Normal file
View File

@ -0,0 +1,6 @@
{{ define "title" }}Bad Request{{ end }}
{{ define "content" }}
<h1>400 Bad Request</h1>
{{ .message }}
{{ end }}

View File

@ -2,6 +2,16 @@
{{ 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
@ -16,11 +26,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" 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">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="setup-admin-password1">Passphrase:</label> <label for="setup-admin-password1">Passphrase:</label>
<input type="password" <input type="password"
name="password"
class="form-control" class="form-control"
id="setup-admin-password1" id="setup-admin-password1"
placeholder="correct horse battery staple" placeholder="correct horse battery staple"
@ -31,7 +42,11 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="setup-admin-password2">Confirm:</label> <label for="setup-admin-password2">Confirm:</label>
<input type="password" class="form-control" id="setup-admin-password2" placeholder="correct horse battery staple"> <input type="password"
name="confirm"
class="form-control"
id="setup-admin-password2"
placeholder="correct horse battery staple">
</div> </div>
<button type="submit" class="btn btn-primary">Continue</button> <button type="submit" class="btn btn-primary">Continue</button>