Sessions, log in-out, app settings GUI

This commit is contained in:
Noah 2017-11-15 06:55:15 -08:00
parent fe84b0c4f1
commit 3d4d69decc
16 changed files with 356 additions and 87 deletions

View File

@ -32,3 +32,9 @@ test:
.PHONY: clean
clean:
rm -rf bin dist
# `make hardclean` cleans EVERY THING, including root/.private, resetting
# your database in the local dev environment. Be careful!
.PHONY: hardclean
hardclean: clean
rm -rf root/.private

View File

@ -3,61 +3,34 @@ package core
import (
"net/http"
"github.com/gorilla/sessions"
"github.com/kirsle/blog/core/forms"
"github.com/gorilla/mux"
"github.com/kirsle/blog/core/models/settings"
"github.com/kirsle/blog/core/models/users"
"github.com/urfave/negroni"
)
// AdminRoutes attaches the admin routes to the app.
func (b *Blog) AdminRoutes(r *mux.Router) {
adminRouter := mux.NewRouter().PathPrefix("/admin").Subrouter().StrictSlash(false)
r.HandleFunc("/admin", b.AdminHandler) // so as to not be "/admin/"
adminRouter.HandleFunc("/settings", b.SettingsHandler)
adminRouter.PathPrefix("/").HandlerFunc(b.PageHandler)
r.PathPrefix("/admin").Handler(negroni.New(
negroni.HandlerFunc(b.LoginRequired),
negroni.Wrap(adminRouter),
))
}
// AdminHandler is the admin landing page.
func (b *Blog) AdminHandler(w http.ResponseWriter, r *http.Request) {
b.RenderTemplate(w, r, "admin/index", nil)
}
// SetupHandler is the initial blog setup route.
func (b *Blog) SetupHandler(w http.ResponseWriter, r *http.Request) {
vars := &Vars{
Form: forms.Setup{},
}
// SettingsHandler lets you configure the app from the frontend.
func (b *Blog) SettingsHandler(w http.ResponseWriter, r *http.Request) {
v := NewVars()
if r.Method == "POST" {
form := forms.Setup{
Username: r.FormValue("username"),
Password: r.FormValue("password"),
Confirm: r.FormValue("confirm"),
}
vars.Form = form
err := form.Validate()
if err != nil {
vars.Error = err
} else {
// Save the site config.
log.Info("Creating default website config file")
s := settings.Defaults()
s.Save()
// Re-initialize the cookie store with the new secret key.
b.store = sessions.NewCookieStore([]byte(s.Security.SecretKey))
log.Info("Creating admin account %s", form.Username)
user := &users.User{
Username: form.Username,
Password: form.Password,
Admin: true,
Name: "Administrator",
}
err := users.Create(user)
if err != nil {
log.Error("Error: %v", err)
vars.Error = err
}
// All set!
b.Login(w, r, user)
b.Redirect(w, "/admin")
return
}
}
b.RenderTemplate(w, r, "admin/setup", vars)
// Get the current settings.
settings, _ := settings.Load()
v.Data["s"] = settings
b.RenderTemplate(w, r, "admin/settings", v)
}

View File

@ -53,31 +53,26 @@ func New(documentRoot, userRoot string) *Blog {
// Initialize the router.
r := mux.NewRouter()
blog.r = r
// Blog setup.
r.HandleFunc("/admin/setup", blog.SetupHandler)
// Admin pages that require a logged-in user.
admin := mux.NewRouter()
admin.HandleFunc("/admin", blog.AdminHandler)
r.PathPrefix("/admin").Handler(negroni.New(
negroni.HandlerFunc(blog.LoginRequired),
negroni.Wrap(admin),
))
r.HandleFunc("/initial-setup", blog.SetupHandler)
r.HandleFunc("/login", blog.LoginHandler)
r.HandleFunc("/logout", blog.LogoutHandler)
r.HandleFunc("/", blog.PageHandler)
blog.AdminRoutes(r)
r.PathPrefix("/").HandlerFunc(blog.PageHandler)
r.NotFoundHandler = http.HandlerFunc(blog.PageHandler)
n := negroni.New(
negroni.NewRecovery(),
negroni.NewLogger(),
negroni.HandlerFunc(blog.SessionLoader),
negroni.HandlerFunc(blog.AuthMiddleware),
)
blog.n = n
n.Use(negroni.HandlerFunc(blog.AuthMiddleware))
n.UseHandler(r)
// Keep references handy elsewhere in the app.
blog.n = n
blog.r = r
return blog
}

View File

@ -8,12 +8,6 @@ import (
"github.com/kirsle/blog/core/models/users"
)
type key int
const (
userKey key = iota
)
// Login logs the browser in as the given user.
func (b *Blog) Login(w http.ResponseWriter, r *http.Request, u *users.User) error {
session, err := b.store.Get(r, "session") // TODO session name

View File

@ -4,7 +4,7 @@ import (
"errors"
)
// Setup is for the initial blog setup page at /admin/setup.
// Setup is for the initial blog setup page at /initial-setup.
type Setup struct {
Username string
Password string

58
core/initial-setup.go Normal file
View File

@ -0,0 +1,58 @@
package core
import (
"net/http"
"github.com/gorilla/sessions"
"github.com/kirsle/blog/core/forms"
"github.com/kirsle/blog/core/models/settings"
"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 := &Vars{
Form: forms.Setup{},
}
if r.Method == "POST" {
form := forms.Setup{
Username: r.FormValue("username"),
Password: r.FormValue("password"),
Confirm: r.FormValue("confirm"),
}
vars.Form = form
err := form.Validate()
if err != nil {
vars.Error = err
} else {
// Save the site config.
log.Info("Creating default website config file")
s := settings.Defaults()
s.Save()
// Re-initialize the cookie store with the new secret key.
b.store = sessions.NewCookieStore([]byte(s.Security.SecretKey))
log.Info("Creating admin account %s", form.Username)
user := &users.User{
Username: form.Username,
Password: form.Password,
Admin: true,
Name: "Administrator",
}
err := users.Create(user)
if err != nil {
log.Error("Error: %v", err)
vars.Error = err
}
// All set!
b.Login(w, r, user)
b.Redirect(w, "/admin")
return
}
}
b.RenderTemplate(w, r, "initial-setup", vars)
}

View File

@ -33,7 +33,7 @@ func New(root string) *DB {
// 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)
log.Debug("[JsonDB] GET %s", document)
if !db.Exists(document) {
return ErrNotFound
}
@ -56,7 +56,7 @@ func (db *DB) Get(document string, v interface{}) error {
// Commit writes a JSON object to the database.
func (db *DB) Commit(document string, v interface{}) error {
log.Debug("COMMIT %s", document)
log.Debug("[JsonDB] COMMIT %s", document)
path := db.toPath(document)
// Ensure the directory tree is ready.
@ -73,7 +73,7 @@ func (db *DB) Commit(document string, v interface{}) error {
// Delete removes a JSON document from the database.
func (db *DB) Delete(document string) error {
log.Debug("DELETE %s", document)
log.Debug("[JsonDB] DELETE %s", document)
path := db.toPath(document)
if _, err := os.Stat(path); os.IsNotExist(err) {
@ -106,15 +106,11 @@ func (db *DB) ListAll(path string) ([]string, error) {
// 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)
log.Debug("[JsonDB] Create directory: %s", directory)
err = os.MkdirAll(directory, 0755)
return err
}

View File

@ -4,13 +4,46 @@ import (
"context"
"net/http"
"github.com/gorilla/sessions"
"github.com/kirsle/blog/core/models/users"
)
type key int
const (
sessionKey key = iota
userKey
)
// SessionLoader gets the Gorilla session store and makes it available on the
// Request context.
func (b *Blog) SessionLoader(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
session, _ := b.store.Get(r, "session")
log.Debug("REQUEST START: %s %s", r.Method, r.URL.Path)
ctx := context.WithValue(r.Context(), sessionKey, session)
next(w, r.WithContext(ctx))
}
// Session returns the current request's session.
func (b *Blog) Session(r *http.Request) *sessions.Session {
ctx := r.Context()
if session, ok := ctx.Value(sessionKey).(*sessions.Session); ok {
return session
}
log.Error(
"Session(): didn't find session in request context! Getting it " +
"from the session store instead.",
)
session, _ := b.store.Get(r, "session")
return session
}
// AuthMiddleware loads the user's authentication state.
func (b *Blog) AuthMiddleware(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
session, _ := b.store.Get(r, "session")
log.Info("Session: %v", session.Values)
session := b.Session(r)
log.Debug("AuthMiddleware() -- session values: %v", session.Values)
if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn {
// They seem to be logged in. Get their user object.
id := session.Values["user-id"].(int)
@ -23,6 +56,7 @@ func (b *Blog) AuthMiddleware(w http.ResponseWriter, r *http.Request, next http.
ctx := context.WithValue(r.Context(), userKey, u)
next(w, r.WithContext(ctx))
return
}
next(w, r)
}
@ -33,8 +67,10 @@ func (b *Blog) LoginRequired(w http.ResponseWriter, r *http.Request, next http.H
if user, ok := ctx.Value(userKey).(*users.User); ok {
if user.ID > 0 {
next(w, r)
return
}
}
log.Info("Redirect away!")
b.Redirect(w, "/login?next="+r.URL.Path)
}

View File

@ -11,6 +11,7 @@ import (
// PageHandler is the catch-all route handler, for serving static web pages.
func (b *Blog) PageHandler(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
log.Debug("Catch-all page handler invoked for request URI: %s", path)
// Remove trailing slashes by redirecting them away.
if len(path) > 1 && path[len(path)-1] == '/' {
@ -65,7 +66,14 @@ func (b *Blog) ResolvePath(path string) (Filepath, error) {
path = strings.TrimPrefix(path, "/")
}
log.Debug("Resolving filepath for URI: %s", path)
// If you need to debug this function, edit this block.
debug := func(tmpl string, args ...interface{}) {
if false {
log.Debug(tmpl, args...)
}
}
debug("Resolving filepath for URI: %s", path)
for _, root := range []string{b.DocumentRoot, b.UserRoot} {
if len(root) == 0 {
continue
@ -78,11 +86,11 @@ func (b *Blog) ResolvePath(path string) (Filepath, error) {
log.Error("%v", err)
}
log.Debug("Expected filepath: %s", absPath)
debug("Expected filepath: %s", absPath)
// Found an exact hit?
if stat, err := os.Stat(absPath); !os.IsNotExist(err) && !stat.IsDir() {
log.Debug("Exact filepath found: %s", absPath)
debug("Exact filepath found: %s", absPath)
return Filepath{path, relPath, absPath}, nil
}
@ -98,7 +106,7 @@ func (b *Blog) ResolvePath(path string) (Filepath, error) {
for _, suffix := range suffixes {
test := absPath + suffix
if stat, err := os.Stat(test); !os.IsNotExist(err) && !stat.IsDir() {
log.Debug("Filepath found via suffix %s: %s", suffix, test)
debug("Filepath found via suffix %s: %s", suffix, test)
return Filepath{path + suffix, relPath + suffix, test}, nil
}
}

View File

@ -6,6 +6,7 @@ import (
// Redirect sends an HTTP redirect response.
func (b *Blog) Redirect(w http.ResponseWriter, location string) {
log.Error("Redirect: %s", location)
w.Header().Set("Location", location)
w.WriteHeader(http.StatusFound)
}
@ -16,6 +17,7 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin
message = []string{"The page you were looking for was not found."}
}
log.Error("HERE 2")
w.WriteHeader(http.StatusNotFound)
err := b.RenderTemplate(w, r, ".errors/404", &Vars{
Message: message[0],
@ -28,6 +30,7 @@ func (b *Blog) NotFound(w http.ResponseWriter, r *http.Request, message ...strin
// Forbidden sends an HTTP 403 Forbidden response.
func (b *Blog) Forbidden(w http.ResponseWriter, r *http.Request, message ...string) {
log.Error("HERE 3")
w.WriteHeader(http.StatusForbidden)
err := b.RenderTemplate(w, r, ".errors/403", nil)
if err != nil {
@ -38,6 +41,7 @@ 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) {
log.Error("HERE 4")
w.WriteHeader(http.StatusBadRequest)
err := b.RenderTemplate(w, r, ".errors/400", &Vars{
Message: message[0],

View File

@ -25,9 +25,24 @@ type Vars struct {
Message string
Flash string
Error error
Data map[interface{}]interface{}
Form forms.Form
}
// NewVars initializes a Vars struct with the custom Data map initialized.
// You may pass in an initial value for this map if you want.
func NewVars(data ...map[interface{}]interface{}) *Vars {
var value map[interface{}]interface{}
if len(data) > 0 {
value = data[0]
} else {
value = make(map[interface{}]interface{})
}
return &Vars{
Data: value,
}
}
// LoadDefaults combines template variables with default, globally available vars.
func (v *Vars) LoadDefaults(r *http.Request) {
// Get the site settings.
@ -36,7 +51,7 @@ func (v *Vars) LoadDefaults(r *http.Request) {
s = settings.Defaults()
}
if s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/admin/setup") {
if s.Initialized == false && !strings.HasPrefix(r.URL.Path, "/initial-setup") {
v.SetupNeeded = true
}
v.Title = s.Site.Title

View File

@ -1,4 +1,6 @@
{{ define "title" }}Untitled{{ end }}
{{ define "scripts" }}Default Scripts{{ end }}
{{ define "layout" }}
<!DOCTYPE html>
<html lang="en">
@ -52,7 +54,7 @@
{{ if .SetupNeeded }}
<div class="alert alert-success">
Your web blog needs to be set up!
Please <a href="/admin/setup">click here</a> to
Please <a href="/initial-setup">click here</a> to
configure your blog.
</div>
{{ end }}
@ -157,6 +159,9 @@
</div>
</footer>
<script type="text/javascript" src="/js/vue.min.js"></script>
{{ template "scripts" or "" }}
</body>
</html>
{{ end }}

154
root/admin/settings.gohtml Normal file
View File

@ -0,0 +1,154 @@
{{ define "title" }}Website Settings{{ end }}
{{ define "content" }}
<form action="/admin/settings" method="POST">
<div id="settings-app" class="card">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link" href="#site"
:class="{ active: currentTab === 'site'}"
v-on:click="currentTab = 'site'">
Settings
</a>
</li>
<!-- <li class="nav-item">
<a class="nav-link" href="#db"
:class="{ active: currentTab === 'db'}"
v-on:click="currentTab = 'db'">
Database
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#security"
:class="{ active: currentTab === 'security'}"
v-on:click="currentTab = 'security'">
Security
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Hello</a>
</li> -->
</ul>
</div>
{{ with .Data.s }}
<div class="card-body" v-if="currentTab === 'site'">
<h3>The Basics</h3>
<div class="form-group">
<label for="title">Title</label>
<input type="text"
class="form-control"
name="title" id="title"
value="{{ .Site.Title }}"
placeholder="Website Title">
</div>
<div class="form-group">
<label for="admin-email">Admin Email</label>
<small class="text-muted">For getting notifications about comments, etc.</small>
<input type="email"
class="form-control"
name="admin-email" id="admin-email"
value="{{ .Site.AdminEmail }}"
placeholder="name@domain.com">
</div>
<h3>Redis Cache</h3>
<p>
Using a <a href="https://redis.io/" target="_blank">Redis</a> cache can
boost the performance of the JSON database by caching documents in
memory instead of always reading from disk.
</p>
<div class="form-check">
<label class="form-check-label">
<input type="checkbox"
class="form-check-input"
name="redis-enabled"
value="true"
{{ if .Redis.Enabled }}checked{{ end }}>
Enable Redis
</label>
</div>
<div class="form-group">
<label for="redis-prefix">Key Prefix</label>
<small class="text-muted">(optional)</small>
<input type="text"
class="form-control"
name="redis-prefix" id="redis-prefix"
value="{{ .Redis.Prefix }}"
placeholder="blog:">
</div>
<div class="form-group">
<label for="redis-host">Redis Host</label>
<input type="text"
class="form-control"
name="redis-host" id="redis-host"
value="{{ .Redis.Host }}"
placeholder="localhost">
</div>
<div class="form-group">
<label for="redis-port">Port</label>
<input type="text"
class="form-control"
name="redis-port" id="redis-port"
value="{{ .Redis.Port }}"
placeholder="6379">
</div>
<div class="form-group">
<label for="redis-db">DB Number</label>
<small class="text-muted">0-15</small>
<input type="text"
class="form-control"
name="redis-db" id="redis-db"
value="{{ .Redis.DB }}"
placeholder="0">
</div>
<div class="form-group">
<label for="redis-prefix">Key Prefix</label>
<small class="text-muted">(optional)</small>
<input type="text"
class="form-control"
name="redis-prefix" id="redis-prefix"
value="{{ .Redis.Prefix }}"
placeholder="blog:">
</div>
</div>
<div class="card-body" v-if="currentTab === 'db'">
</div>
<div class="card-body" v-if="currentTab === 'security'">
<div class="form-check">
<label class="form-check-label">
<input type="checkbox"
class="form-check-input"
name="redis-enabled"
value="true"
{{ if .Redis.Enabled }}checked{{ end }}>
Enable Redis
</label>
</div>
<div class="form-group">
<label for="redis-prefix">Key Prefix</label>
<small class="text-muted">(optional)</small>
<input type="text"
class="form-control"
name="redis-prefix" id="redis-prefix"
value="{{ .Redis.Prefix }}"
placeholder="blog:">
</div>
</div>
{{ end }}
</div>
</form>
{{ end }}
{{ define "scripts" }}
<script type="text/javascript" src="/admin/settings.js"></script>
{{ end }}

19
root/admin/settings.js Normal file
View File

@ -0,0 +1,19 @@
var app = new Vue({
el: "#settings-app",
data: {
currentTab: "site",
},
mounted: function() {
var self = this;
var m = window.location.hash.match(/^#(\w+?)$/)
if (m) {
self.currentTab = m[1];
}
},
methods: {
load: function() {
},
}
})

View File

@ -13,7 +13,7 @@
predictable for an attacker to guess.
</p>
<form method="POST" action="/admin/setup">
<form method="POST" action="/initial-setup">
<div class="form-group">
<label for="setup-admin-username">Admin username:</label>
<input type="text"

6
root/js/vue.min.js vendored Normal file

File diff suppressed because one or more lines are too long