Major refactor

- use embedded static assets
- added pw management subcommands
This commit is contained in:
Stephen McQuay 2015-08-09 11:17:05 -07:00
parent 5ed3bbfcc3
commit a6b7c13fdb
12 changed files with 400 additions and 138 deletions

36
auth.go Normal file
View File

@ -0,0 +1,36 @@
package allowances
import (
"encoding/json"
"log"
"net/http"
)
const sessionName = "allowances"
type authed func(w http.ResponseWriter, r *http.Request, uid string) error
func (a *Allowances) protected(handler authed) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := a.store.Get(r, sessionName)
if err != nil {
log.Printf("%+v", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
u, ok := session.Values["uuid"]
if !ok {
http.Redirect(
w, r,
prefix["login"],
http.StatusTemporaryRedirect,
)
return
}
err = handler(w, r, u.(string))
if err != nil {
json.NewEncoder(w).Encode(NewFailure(err.Error()))
return
}
}
}

105
cmd/allowances/main.go Normal file
View File

@ -0,0 +1,105 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"strconv"
"github.com/bgentry/speakeasy"
"mcquay.me/allowances"
)
const usage = `allowances app
subcommands:
pw -- manage password file
serve -- serve webapp
`
const pwUsage = `allowances pw
subcommands:
add <passes.json>
test <passes.json>
`
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, usage)
os.Exit(1)
}
subcommand := os.Args[1]
switch subcommand {
case "pw":
pwCmd := os.Args[2:]
if len(pwCmd) != 2 {
fmt.Fprintf(os.Stderr, "%s\n", pwUsage)
os.Exit(1)
}
switch pwCmd[0] {
case "add":
pw, err := speakeasy.Ask("new pass: ")
if err != nil {
fmt.Fprintf(os.Stderr, "failure to get password: %v", err)
os.Exit(1)
}
if err := allowances.AddPassword(pwCmd[1], pw); err != nil {
fmt.Fprintf(os.Stderr, "problem adding password: %v", err)
os.Exit(1)
}
case "test":
passes, _, err := allowances.GetHashes(pwCmd[1])
if err != nil {
fmt.Fprintf(os.Stderr, "problem opening passes file: %v", err)
os.Exit(1)
}
pw, err := speakeasy.Ask("check password: ")
if err != nil {
panic(err)
}
ok, err := passes.Check(pw)
if err != nil {
panic(err)
}
if !ok {
fmt.Fprintf(os.Stderr, "bad password")
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "%s\n", pwUsage)
os.Exit(1)
}
case "serve":
sm := http.NewServeMux()
dbfile := os.Getenv("DB")
passfile := os.Getenv("PASSES")
_, err := allowances.NewAllowances(sm, dbfile, passfile, os.Getenv("STATIC"))
if err != nil {
fmt.Fprintf(os.Stderr, "unable to initialize web server: %v\n", err)
os.Exit(1)
}
port := 8000
if os.Getenv("PORT") != "" {
p, err := strconv.Atoi(os.Getenv("PORT"))
if err != nil {
fmt.Fprintf(os.Stderr, "problem parsing port from env: %v\n", err)
os.Exit(1)
}
port = p
}
addr := fmt.Sprintf(":%d", port)
log.Printf("%+v", addr)
err = http.ListenAndServe(addr, sm)
if err != nil {
panic(err)
}
default:
fmt.Fprintf(os.Stderr, "unknown subcommand %s\n\n%s\n", subcommand, usage)
os.Exit(1)
}
}

67
db.go
View File

@ -1,58 +1,67 @@
package main package allowances
import ( import (
"golang.org/x/crypto/bcrypt"
"encoding/json" "encoding/json"
"io/ioutil" "io/ioutil"
"log" "log"
"os"
"sync" "sync"
"golang.org/x/crypto/bcrypt"
) )
var dbMutex = sync.RWMutex{} func GetHashes(filename string) (Passes, bool, error) {
r := []string{}
func get_passes(filename string) (cur_passes []string, err error) { exists := false
b, err := ioutil.ReadFile(filename) if !Exists(filename) {
if err != nil { return r, exists, nil
log.Fatal(err)
} }
err = json.Unmarshal(b, &cur_passes) exists = true
f, err := os.Open(filename)
if err != nil { if err != nil {
log.Fatal(err) return nil, exists, err
} }
return err = json.NewDecoder(f).Decode(&r)
if err != nil {
return nil, exists, err
}
return r, exists, nil
} }
func add_password(filename, new_pw string) (err error) { func AddPassword(filename, pw string) error {
cur_passes, err := get_passes(filename) curPasses, _, err := GetHashes(filename)
if err != nil { if err != nil {
log.Fatal(err) return err
} }
hpass, err := bcrypt.GenerateFromPassword( hpass, err := bcrypt.GenerateFromPassword(
[]byte(*add_pw), bcrypt.DefaultCost) []byte(pw), bcrypt.DefaultCost)
cur_passes = append(cur_passes, string(hpass)) curPasses = append(curPasses, string(hpass))
b, err := json.Marshal(cur_passes)
err = ioutil.WriteFile(filename, b, 0644) f, err := os.Create(filename)
if err != nil { if err != nil {
log.Fatal(err) return err
} }
return if err := json.NewEncoder(f).Encode(curPasses); err != nil {
return err
}
return nil
} }
func check_password(filename, attempt string) (result bool) { type Passes []string
hashes, err := get_passes(filename)
if err != nil { func (p Passes) Check(attempt string) (bool, error) {
log.Fatal(err) // TODO: parallelize
} for _, hash := range p {
for _, hash := range hashes {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(attempt)) err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(attempt))
if err == nil { if err == nil {
result = true return true, err
return
} }
} }
return return false, nil
} }
var dbMutex = sync.RWMutex{}
func loadChildren(filename string) (children map[string]int) { func loadChildren(filename string) (children map[string]int) {
dbMutex.RLock() dbMutex.RLock()
defer dbMutex.RUnlock() defer dbMutex.RUnlock()

12
fs.go Normal file
View File

@ -0,0 +1,12 @@
package allowances
import "os"
func Exists(path string) bool {
if _, err := os.Stat(path); err != nil {
return false
} else if os.IsNotExist(err) {
return false
}
return true
}

6
gen.go Normal file
View File

@ -0,0 +1,6 @@
package allowances
//go:generate go get github.com/jteeuwen/go-bindata/...
//go:generate go get github.com/elazarl/go-bindata-assetfs/...
//go:generate rm -vf static.go
//go:generate go-bindata -o static.go -pkg=allowances static/... templates/...

View File

@ -1,60 +1,118 @@
package main package allowances
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/gorilla/sessions"
) )
func homeHandler(w http.ResponseWriter, req *http.Request) { func init() {
session, _ := store.Get(req, "creds") log.SetFlags(log.Lshortfile | log.Ltime)
loggedIn := session.Values["logged in"]
if loggedIn == nil {
http.Redirect(w, req, "/login", http.StatusSeeOther)
return
}
children := loadChildren(*db_file)
T("index.html").Execute(w, map[string]interface{}{
"children": children})
} }
func loginHandler(w http.ResponseWriter, req *http.Request) { type failure struct {
pwAttempt := req.FormValue("passwd") Success bool `json:"success"`
if check_password(*passes_file, pwAttempt) { Error string `json:"error"`
session, _ := store.Get(req, "creds") }
session.Values["logged in"] = true
func NewFailure(msg string) *failure {
return &failure{
Success: false,
Error: msg,
}
}
type Allowances struct {
db string
hashes Passes
store *sessions.CookieStore
}
func NewAllowances(sm *http.ServeMux, dbfile, passfile, staticFiles string) (*Allowances, error) {
var err error
tmpls, err = getTemplates()
if err != nil {
return nil, err
}
hashes, exists, err := GetHashes(passfile)
if !exists {
return nil, fmt.Errorf("passes file doesn't exist: %q", passfile)
}
if err != nil {
return nil, err
}
if !Exists(dbfile) {
return nil, fmt.Errorf("child db file doesn't exist: %q", dbfile)
}
r := &Allowances{
db: dbfile,
hashes: hashes,
store: sessions.NewCookieStore([]byte("hello world")),
}
addRoutes(sm, r, staticFiles)
return r, nil
}
func (a *Allowances) home(w http.ResponseWriter, req *http.Request, uid string) error {
children := loadChildren(a.db)
tmpls["home"].Execute(w, map[string]interface{}{"children": children})
return nil
}
func (a *Allowances) login(w http.ResponseWriter, req *http.Request) {
attempt := req.FormValue("passwd")
ok, err := a.hashes.Check(attempt)
if err != nil {
b, _ := json.Marshal(NewFailure(err.Error()))
http.Error(w, string(b), http.StatusBadRequest)
return
}
if ok {
session, _ := a.store.Get(req, sessionName)
session.Values["uuid"] = "me"
session.Save(req, w) session.Save(req, w)
http.Redirect(w, req, "/", http.StatusSeeOther) http.Redirect(w, req, "/", http.StatusSeeOther)
return return
} }
T("login.html").Execute(w, map[string]interface{}{}) tmpls["login"].Execute(w, map[string]interface{}{})
} }
func logoutHandler(w http.ResponseWriter, req *http.Request) { func (a *Allowances) logout(w http.ResponseWriter, req *http.Request, u string) error {
session, _ := store.Get(req, "creds") session, err := a.store.Get(req, sessionName)
delete(session.Values, "logged in") if err != nil {
return err
}
delete(session.Values, "uuid")
session.Save(req, w) session.Save(req, w)
http.Redirect(w, req, "/", http.StatusSeeOther) http.Redirect(w, req, "/", http.StatusSeeOther)
return return nil
} }
func addHandler(w http.ResponseWriter, req *http.Request) { func (a *Allowances) add(w http.ResponseWriter, req *http.Request, uid string) error {
path := req.URL.Path[len(addPath):] path := req.URL.Path[len(prefix["add"]):]
bits := strings.Split(path, "/") bits := strings.Split(path, "/")
child := bits[0] child := bits[0]
amount, err := strconv.Atoi(bits[1]) amount, err := strconv.Atoi(bits[1])
if err != nil { if err != nil {
log.Fatal("couldn't parse a dollar amount", err) return fmt.Errorf("couldn't parse a dollar amount: %v", err)
} }
children := loadChildren(*db_file) children := loadChildren(a.db)
children[child] += amount children[child] += amount
defer dumpChildren(*db_file, children) defer dumpChildren(a.db, children)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
b, err := json.Marshal(map[string]interface{}{ b, err := json.Marshal(map[string]interface{}{
"amount": dollarize(children[child]), "amount": dollarize(children[child]),
"name": child, "name": child,
}) })
w.Write(b) if err != nil {
return err
}
w.Write(b)
return nil
} }

43
main.go
View File

@ -1,43 +0,0 @@
package main
import (
"flag"
"fmt"
"github.com/gorilla/sessions"
"html/template"
"log"
"net/http"
)
var addr = flag.String("addr", ":8000", "address I'll listen on.")
var static_files = flag.String("static", "./static", "location of static files")
var passes_file = flag.String("passes", "passwds.json", "the password database")
var db_file = flag.String("children", "children.json", "the children database")
var template_dir = flag.String("templates", "templates", "template dir")
var add_pw = flag.String("passwd", "", "add this pass to the db")
var check_pw = flag.String("checkpw", "", "check if this pw is in db")
var addPath = "/add/"
var store = sessions.NewCookieStore([]byte("hello world"))
var templates *template.Template
func main() {
flag.Parse()
if *add_pw != "" {
add_password(*passes_file, *add_pw)
} else if *check_pw != "" {
fmt.Printf("valid password: %v\n",
check_password(*passes_file, *check_pw))
} else {
http.HandleFunc("/", homeHandler)
http.HandleFunc("/login", loginHandler)
http.HandleFunc("/logout", logoutHandler)
http.HandleFunc(addPath, addHandler)
http.Handle("/s/", http.StripPrefix("/s/",
http.FileServer(http.Dir(*static_files))))
if err := http.ListenAndServe(*addr, nil); err != nil {
log.Fatal("ListenAndServe:", err)
}
}
}

53
routes.go Normal file
View File

@ -0,0 +1,53 @@
package allowances
import (
"net/http"
"github.com/elazarl/go-bindata-assetfs"
"github.com/gorilla/context"
)
var prefix map[string]string
func addRoutes(sm *http.ServeMux, a *Allowances, staticFiles string) {
prefix = map[string]string{
"static": "/s/",
"auth": "/api/v0/auth/",
"reset": "/api/v0/auth/reset/",
"add": "/add/",
"login": "/login/",
"logout": "/logout/",
}
sm.HandleFunc("/", a.protected(a.home))
sm.HandleFunc(prefix["login"], a.login)
sm.HandleFunc(prefix["logout"], a.protected(a.logout))
sm.HandleFunc(prefix["add"], a.protected(a.add))
if staticFiles == "" {
sm.Handle(
prefix["static"],
http.StripPrefix(
prefix["static"],
http.FileServer(
&assetfs.AssetFS{
Asset: Asset,
AssetDir: AssetDir,
Prefix: "static",
},
),
),
)
} else {
sm.Handle(
prefix["static"],
http.StripPrefix(
prefix["static"],
http.FileServer(http.Dir(staticFiles)),
),
)
}
context.ClearHandler(sm)
}

View File

@ -1,34 +0,0 @@
package main
import (
"fmt"
"html/template"
"path/filepath"
"sync"
)
var cachedTemplates = map[string]*template.Template{}
var cachedMutex sync.Mutex
func dollarize(value int) string {
return fmt.Sprintf("$%0.2f", float32(value)/100.0)
}
var funcs = template.FuncMap{
"dollarize": dollarize,
}
func T(name string) *template.Template {
cachedMutex.Lock()
defer cachedMutex.Unlock()
if t, ok := cachedTemplates[name]; ok {
return t
}
t := template.New("_base.html").Funcs(funcs)
t = template.Must(t.ParseFiles(
"templates/_base.html",
filepath.Join(*template_dir, name),
))
cachedTemplates[name] = t
return t
}

60
templates.go Normal file
View File

@ -0,0 +1,60 @@
package allowances
import (
"fmt"
"html/template"
"log"
"strings"
)
func dollarize(value int) string {
return fmt.Sprintf("$%0.2f", float32(value)/100.0)
}
type tmap map[string]*template.Template
var tmpls tmap
func getTemplates() (tmap, error) {
var err error
funcMap := template.FuncMap{
"title": strings.Title,
"dollarize": dollarize,
}
base, err := Asset("templates/base.html")
if err != nil {
return nil, err
}
tmpl, err := template.New("base").Funcs(funcMap).Parse(string(base))
if err != nil {
return nil, err
}
templates := make(map[string]*template.Template)
templateFiles := []struct {
name string
path string
}{
{"home", "templates/index.html"},
{"login", "templates/login.html"},
}
for _, tf := range templateFiles {
a, err := Asset(tf.path)
if err != nil {
return nil, err
}
t, err := tmpl.Clone()
if err != nil {
return nil, err
}
t, err = t.Parse(string(a))
if err != nil {
log.Printf("XXX: %+v", err)
return nil, err
}
templates[tf.name] = t
}
return templates, nil
}

View File

@ -3,7 +3,7 @@
{{ define "content" }} {{ define "content" }}
<div class="container pw"> <div class="container pw">
<div class="row-fluid"> <div class="row-fluid">
<form id="login" class="form-inline offset4 span3" action="/login" method="post"> <form id="login" class="form-inline offset4 span3" action="/login/" method="post">
<div class="input-prepend input-append span2"> <div class="input-prepend input-append span2">
<span class="add-on"> <span class="add-on">
<i class="icon-lock"></i> <i class="icon-lock"></i>