diff --git a/Makefile b/Makefile index 08a9940..705f758 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ build: # `make run` to run it in debug mode. .PHONY: run run: - ./go-reload main.go -debug + ./go-reload main.go -debug root # `make test` to run unit tests. .PHONY: test diff --git a/core/admin.go b/core/admin.go index 36fd626..5191e92 100644 --- a/core/admin.go +++ b/core/admin.go @@ -1,10 +1,57 @@ package core import ( + "fmt" "net/http" + + "github.com/kirsle/blog/core/models/users" ) // SetupHandler is the initial blog setup route. 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) } diff --git a/core/app.go b/core/app.go index eaa75d1..f32cbb7 100644 --- a/core/app.go +++ b/core/app.go @@ -2,8 +2,10 @@ package core import ( "net/http" + "path/filepath" "github.com/gorilla/mux" + "github.com/kirsle/blog/core/jsondb" "github.com/urfave/negroni" ) @@ -16,6 +18,8 @@ type Blog struct { DocumentRoot string UserRoot string + DB *jsondb.DB + // Web app objects. n *negroni.Negroni // Negroni middleware manager r *mux.Router // Router @@ -26,7 +30,9 @@ func New(documentRoot, userRoot string) *Blog { blog := &Blog{ DocumentRoot: documentRoot, UserRoot: userRoot, + DB: jsondb.New(filepath.Join(userRoot, ".private")), } + r := mux.NewRouter() blog.r = r r.HandleFunc("/admin/setup", blog.SetupHandler) diff --git a/core/jsondb/jsondb.go b/core/jsondb/jsondb.go new file mode 100644 index 0000000..488b7b6 --- /dev/null +++ b/core/jsondb/jsondb.go @@ -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") +} diff --git a/core/jsondb/log.go b/core/jsondb/log.go new file mode 100644 index 0000000..f83047c --- /dev/null +++ b/core/jsondb/log.go @@ -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.") + } +} diff --git a/core/models/base.go b/core/models/base.go new file mode 100644 index 0000000..15c2d26 --- /dev/null +++ b/core/models/base.go @@ -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 +} diff --git a/core/models/users/users.go b/core/models/users/users.go new file mode 100644 index 0000000..6c0695e --- /dev/null +++ b/core/models/users/users.go @@ -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" +} diff --git a/core/responses.go b/core/responses.go index 3a5fda5..5a8287c 100644 --- a/core/responses.go +++ b/core/responses.go @@ -22,15 +22,28 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin }) if err != nil { 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) { w.WriteHeader(http.StatusForbidden) err := b.RenderTemplate(w, r, ".errors/403", 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()")) } } diff --git a/main.go b/main.go index dada670..eabbb49 100644 --- a/main.go +++ b/main.go @@ -6,8 +6,11 @@ package main import ( "flag" + "fmt" + "os" "github.com/kirsle/blog/core" + "github.com/kirsle/blog/core/jsondb" ) // Build-time config constants. @@ -32,8 +35,16 @@ func init() { func main() { flag.Parse() + userRoot := flag.Arg(0) + if userRoot == "" { + fmt.Printf("Need user root\n") + os.Exit(1) + } - app := core.New(DocumentRoot, "") - app.Debug = fDebug + app := core.New(DocumentRoot, userRoot) + if fDebug { + app.Debug = true + jsondb.SetDebug(true) + } app.ListenAndServe(fAddress) } diff --git a/root/.errors/400.gohtml b/root/.errors/400.gohtml new file mode 100644 index 0000000..6af6a61 --- /dev/null +++ b/root/.errors/400.gohtml @@ -0,0 +1,6 @@ +{{ define "title" }}Bad Request{{ end }} +{{ define "content" }} +

400 Bad Request

+ +{{ .message }} +{{ end }} diff --git a/root/admin/setup.gohtml b/root/admin/setup.gohtml index ee2f456..295f46e 100644 --- a/root/admin/setup.gohtml +++ b/root/admin/setup.gohtml @@ -2,6 +2,16 @@ {{ define "content" }}

Initial Setup

+{{ if .errors }} +

Please correct the following errors:

+ + +{{ end }} +

Welcome to your new web blog! To get started, you'll need to create a username and password to be your admin user. You can create additional @@ -16,11 +26,12 @@

- +
- +