Compare commits

..

16 Commits

Author SHA1 Message Date
Chris Sexton 6988064309 adoc support
* These changes are unverified and I don't know what they're really for
* Ref #7
2020-07-23 12:13:58 -04:00
cws b15a85131f Merge pull request 'authorization: only public entries without login' (#21) from 6_public_tag into master
Reviewed-on: #21
2020-03-18 18:08:21 +00:00
Chris Sexton 7f077cfe31 authorization: only public entries without login
Fixes #6
2020-03-18 14:07:34 -04:00
Chris Sexton 68b485c36f esc: fix string quotes 2020-03-17 12:57:04 -04:00
Chris Sexton d9a688dbd1 docker: change container to work with esc
* Also fix esc generate command
2020-03-17 10:36:23 -04:00
cws e504a660ae Merge pull request 'gen: switch to esc for static generation' (#18) from generate into master
Reviewed-on: #18
2020-03-17 09:57:30 +00:00
Chris Sexton 8a785f964f gen: switch to esc for static generation 2020-03-17 05:52:19 -04:00
cws bd7568a159 Merge pull request 'auth: remove console.log' (#17) from users into master
Reviewed-on: #17
2020-03-16 22:36:28 +00:00
Chris Sexton a1d39c3930 auth: remove console.log 2020-03-16 18:36:06 -04:00
cws 191dbfa23a Merge pull request 'users' (#16) from users into master
Reviewed-on: #16
2020-03-16 20:29:55 +00:00
Chris Sexton 516f4610dc auth: user login/key use on the frontend 2020-03-16 16:28:23 -04:00
Chris Sexton 1b8e5b4d70 auth: auth flow complete on server-side 2020-03-15 09:40:47 -04:00
Chris Sexton ce02dca041 users: add authentication/key support to model 2020-03-15 08:29:17 -04:00
Chris Sexton b3eda2c3cf Merge branch 'master' into users
* master:
  slug: create slug from md every time
  add dockerfile
  fix a few package issues for docker build
  remove todo file
  update todo
  update todo
  frontend theming; md content-types
2020-03-15 07:03:25 -04:00
Chris Sexton 98d9d8a899 users: Merge WIP into users 2020-03-15 06:44:27 -04:00
Chris Sexton ceaa8e6a96 users 2019-11-10 19:26:57 -05:00
25 changed files with 5312 additions and 1722 deletions

1
.gitignore vendored
View File

@ -264,3 +264,4 @@ frontend/dist
*-packr.go *-packr.go
env.make env.make
cabinet cabinet
esc.go

123
auth/auth.go Normal file
View File

@ -0,0 +1,123 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"time"
"github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt"
"code.chrissexton.org/cws/cabinet/config"
"code.chrissexton.org/cws/cabinet/db"
)
type User struct {
*db.Database
ID int64
Name string
Hash []byte
AuthKey string `db:"auth_key"`
Invalidate time.Time
}
func PrepareTable(tx *sqlx.Tx) error {
q := `create table if not exists users (
id integer primary key,
name text unique not null,
hash text not null,
auth_key text,
invalidate datetime
)`
_, err := tx.Exec(q)
return err
}
func makeKey() (string, error) {
keySize := config.GetInt("key.size", 10)
buf := make([]byte, keySize)
_, err := rand.Read(buf)
if err != nil {
return "", err
}
key := hex.EncodeToString(buf)
log.Debug().Msgf("Encoded secret key %s as %s", string(buf), key)
return key, nil
}
func New(db *db.Database, name, password string) (*User, error) {
q := `insert or replace into users values (null, ?, ?, ?, ?)`
key, err := makeKey()
if err != nil {
return nil, err
}
invalidate := time.Now().Add(time.Duration(config.GetInt("invalidate.hours", 7*24)) * time.Hour)
hash, err := bcrypt.GenerateFromPassword([]byte(password), config.GetInt("hash.cost", 10))
if err != nil {
return nil, err
}
res, err := db.Exec(q, name, hash, key, invalidate)
if err != nil {
return nil, err
}
id, err := res.LastInsertId()
if err != nil {
return nil, err
}
u := &User{
ID: id,
Name: name,
AuthKey: key,
Invalidate: invalidate,
}
u.Set(password)
return u, nil
}
func Get(db *db.Database, name string) (*User, error) {
q := `select * from users where name = ?`
u := &User{}
if err := db.Get(u, q, name); err != nil {
return nil, err
}
return u, nil
}
func (u *User) Set(newPassword string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 0)
if err != nil {
return err
}
u.Hash = hash
return nil
}
func (u *User) Validate(password string) bool {
err := bcrypt.CompareHashAndPassword(u.Hash, []byte(password))
if err != nil {
log.Debug().Err(err).Msg("incorrect credentials")
return false
}
return true
}
func GetByKey(db *db.Database, key string) (*User, error) {
q := `select * from users where auth_key = ?`
u := &User{}
invalid := errors.New("invalid key")
if err := db.Get(u, q, key); err != nil {
if err.Error() == "sql: no rows in result set" {
return nil, invalid
}
return nil, err
}
if u.Invalidate.Before(time.Now()) {
return nil, invalid
}
return u, nil
}

78
auth/auth_test.go Normal file
View File

@ -0,0 +1,78 @@
package auth
import (
"testing"
"github.com/stretchr/testify/assert"
"code.chrissexton.org/cws/cabinet/db"
)
func TestMakeKey(t *testing.T) {
k, err := makeKey()
assert.Nil(t, err)
assert.NotEmpty(t, k)
}
func TestGetByKey(t *testing.T) {
d, err := db.New(":memory:")
assert.Nil(t, err)
tx, err := d.Beginx()
assert.Nil(t, err)
err = PrepareTable(tx)
assert.Nil(t, err)
err = tx.Commit()
assert.Nil(t, err)
u, err := New(d, "test", "abc")
assert.Nil(t, err)
u2, err := GetByKey(d, u.AuthKey)
assert.Nil(t, err)
assert.Equal(t, u.ID, u2.ID)
}
func TestGetByKeyFailure(t *testing.T) {
d, err := db.New(":memory:")
assert.Nil(t, err)
tx, err := d.Beginx()
assert.Nil(t, err)
err = PrepareTable(tx)
assert.Nil(t, err)
err = tx.Commit()
assert.Nil(t, err)
_, err = New(d, "test", "abc")
assert.Nil(t, err)
u2, err := GetByKey(d, "foobar")
assert.Error(t, err)
assert.Nil(t, u2)
}
func TestGet(t *testing.T) {
d, err := db.New(":memory:")
assert.Nil(t, err)
tx, err := d.Beginx()
assert.Nil(t, err)
err = PrepareTable(tx)
assert.Nil(t, err)
err = tx.Commit()
assert.Nil(t, err)
u, err := New(d, "test", "abc")
assert.Nil(t, err)
u2, err := Get(d, "test")
assert.Nil(t, err)
assert.Equal(t, u.ID, u2.ID)
}
func TestUser_Validate(t *testing.T) {
d, err := db.New(":memory:")
assert.Nil(t, err)
tx, err := d.Beginx()
assert.Nil(t, err)
err = PrepareTable(tx)
assert.Nil(t, err)
err = tx.Commit()
assert.Nil(t, err)
u, err := New(d, "test", "abc")
assert.Nil(t, err)
actual := u.Validate("abc")
assert.True(t, actual)
}

24
config/config.go Normal file
View File

@ -0,0 +1,24 @@
package config
import (
"os"
"strconv"
"strings"
)
func GetInt(key string, fallback int) int {
v := Get(key, strconv.Itoa(fallback))
if out, err := strconv.Atoi(v); err == nil {
return out
}
return fallback
}
func Get(key, fallback string) string {
key = strings.ToUpper(key)
key = strings.ReplaceAll(key, ".", "_")
if v, found := os.LookupEnv(key); found {
return v
}
return fallback
}

View File

@ -2,6 +2,8 @@ package db
import "github.com/jmoiron/sqlx" import "github.com/jmoiron/sqlx"
import _ "github.com/mattn/go-sqlite3"
type Database struct { type Database struct {
*sqlx.DB *sqlx.DB
} }

View File

@ -1,4 +1,4 @@
FROM alpine:edge FROM golang:alpine
RUN apk add --no-cache git RUN apk add --no-cache git
RUN apk add --no-cache musl-dev RUN apk add --no-cache musl-dev
@ -27,8 +27,7 @@ ENV TZ America/New_York
# RUN yarn global add @vue/cli # RUN yarn global add @vue/cli
RUN cd $SRC_DIR/frontend; yarn && yarn build RUN cd $SRC_DIR/frontend; yarn && yarn build
RUN go get -u github.com/gobuffalo/packr/v2/packr2 RUN go get -u github.com/mjibson/esc
RUN cd $SRC_DIR; $HOME/go/bin/packr2 RUN cd $SRC_DIR; go generate && go get ./... && go build -o /app/cabinet
RUN cd $SRC_DIR; go get ./...; go build -o /app/cabinet
ENTRYPOINT ["/app/cabinet", "-httpAddr=0.0.0.0:5673", "-db=/app/var/cabinet.db"] ENTRYPOINT ["/app/cabinet", "-httpAddr=0.0.0.0:5673", "-db=/app/var/cabinet.db"]

View File

@ -2,13 +2,16 @@ package entry
import ( import (
"fmt" "fmt"
"io"
"os/exec"
"regexp" "regexp"
"strings" "strings"
"time" "time"
"code.chrissexton.org/cws/cabinet/db"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"code.chrissexton.org/cws/cabinet/db"
) )
type Entry struct { type Entry struct {
@ -52,7 +55,7 @@ func PrepareTable(tx *sqlx.Tx) error {
return nil return nil
} }
func NewFromMd(db *db.Database, body string) *Entry { func NewFromAdoc(db *db.Database, body string) *Entry {
e := New(db) e := New(db)
e.Content = body e.Content = body
e.Title = e.GenerateTitle() e.Title = e.GenerateTitle()
@ -60,6 +63,33 @@ func NewFromMd(db *db.Database, body string) *Entry {
return e return e
} }
func pandocMdToAdoc(body string) string {
log.Debug().Str("input", body).Msgf("converting md->adoc")
cmd := exec.Command("pandoc", "-f", "commonmark", "-t", "asciidoctor")
stdin, err := cmd.StdinPipe()
if err != nil {
log.Error().Err(err).Msgf("could not get stdin")
}
go func() {
defer stdin.Close()
io.WriteString(stdin, body)
}()
out, err := cmd.CombinedOutput()
if err != nil {
log.Error().Err(err).Msgf("could not get stdout")
}
log.Debug().Msgf("md->adoc: %s", out)
return string(out)
}
func NewFromMd(db *db.Database, body string) *Entry {
body = pandocMdToAdoc(body)
return NewFromAdoc(db, body)
}
func New(db *db.Database) *Entry { func New(db *db.Database) *Entry {
e := Entry{ e := Entry{
db: db, db: db,
@ -94,27 +124,49 @@ func GetByID(db *db.Database, id int64) (Entry, error) {
return e, e.populateTags() return e, e.populateTags()
} }
func Search(db *db.Database, query string) ([]*Entry, error) { func SearchByTag(db *db.Database, query string, tags []string) ([]*Entry, error) {
entries := []*Entry{} entries := []*Entry{}
log.Debug().Str("query", query).Msg("searching") query = fmt.Sprintf("%%%s%%", query)
if query != "" { log.Debug().Str("tag query", query).Int("len(tags)", len(tags)).Msg("searching")
q := `select * from entries where content like ? order by updated desc`
err := db.Select(&entries, q, "%"+query+"%") if len(tags) > 0 {
q := `select e.*
from entries e
inner join tags t
on e.id=t.entry_id
where
t.name in (?)
AND content like ?
order by updated desc`
q, args, err := sqlx.In(q, tags, query)
if err != nil {
return nil, err
}
err = db.Select(&entries, q, args...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} else { } else {
q := `select * from entries order by updated desc` q := `select e.*
err := db.Select(&entries, q) from entries e
where
content like ?
order by updated desc`
err := db.Select(&entries, q, query)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
for _, e := range entries { for _, e := range entries {
e.db = db e.db = db
e.Title = e.GenerateTitle() e.Title = e.GenerateTitle()
e.populateTags() e.populateTags()
} }
return entries, nil return entries, nil
} }
@ -277,3 +329,12 @@ func (e *Entry) Create() error {
tx.Commit() tx.Commit()
return nil return nil
} }
func (e *Entry) HasTag(tag string) bool {
for _, t := range e.Tags {
if strings.ToLower(tag) == strings.ToLower(t) {
return true
}
}
return false
}

2
esc_gen.go Normal file
View File

@ -0,0 +1,2 @@
//go:generate esc -prefix frontend/dist -o esc.go frontend/dist
package main

View File

@ -8,17 +8,19 @@
"lint": "vue-cli-service lint" "lint": "vue-cli-service lint"
}, },
"dependencies": { "dependencies": {
"@vue/cli": "^4.0.5",
"asciidoctor": "^2.0.3", "asciidoctor": "^2.0.3",
"axios": "^0.19.0", "axios": "^0.19.0",
"jquery": "^1.9.1",
"popper.js": "^1.14.7",
"bootstrap": "^4.3.1", "bootstrap": "^4.3.1",
"bootstrap-vue": "^2.0.4", "bootstrap-vue": "^2.0.4",
"bootswatch": "^4.3.1", "bootswatch": "^4.3.1",
"brace": "latest", "brace": "latest",
"core-js": "^3.3.2", "core-js": "^3.3.2",
"jquery": "^1.9.1",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"popper.js": "^1.14.7",
"vue": "^2.6.10", "vue": "^2.6.10",
"vue-cookies": "^1.7.0",
"vue-router": "^3.1.3", "vue-router": "^3.1.3",
"vue2-ace-editor": "^0.0.15", "vue2-ace-editor": "^0.0.15",
"vuex": "^3.0.1" "vuex": "^3.0.1"
@ -34,7 +36,7 @@
"eslint-plugin-vue": "^5.0.0", "eslint-plugin-vue": "^5.0.0",
"sass": "^1.23.0", "sass": "^1.23.0",
"sass-loader": "^8.0.0", "sass-loader": "^8.0.0",
"webpack": "^4.36.0", "vue-template-compiler": "^2.6.10",
"vue-template-compiler": "^2.6.10" "webpack": "^4.36.0"
} }
} }

View File

@ -33,6 +33,15 @@
components: { components: {
Error Error
}, },
created() {
if (!this.$store.state.key) {
let key = this.$cookies.get('key')
if (key) {
this.$store.commit('setKey', key)
return
}
}
},
methods: { methods: {
newFile: function() { newFile: function() {
this.$store.dispatch('newFile') this.$store.dispatch('newFile')

View File

@ -0,0 +1,71 @@
<template>
<b-form @submit="onSubmit" @reset="onReset">
<b-form-group
id="username"
label="Username"
label-for="username-input">
<b-form-input
id="username-input"
v-model="form.username"
required></b-form-input>
</b-form-group>
<b-form-group
id="password"
label="Password"
label-for="password-input">
<b-form-input
id="password-input"
v-model="form.password"
type="password"
required></b-form-input>
</b-form-group>
<b-form-group class="justify-content-md-center align-content-center">
<b-button
id="login-button"
type="submit"
variant="primary">Submit
</b-button>
<b-button
id="reset-button"
type="reset"
variant="danger">Reset
</b-button>
</b-form-group>
</b-form>
</template>
<script>
export default {
name: "Login",
data() {
return {
form: {
username: '',
password: ''
}
}
},
methods: {
onSubmit(evt) {
evt.preventDefault()
this.$store.dispatch('login', {username: this.form.username, password: this.form.password})
.then(() => {
this.$cookies.set('key', this.$store.state.key)
if (!this.$route.params.returnTo)
this.$router.push("/")
else
this.$router.push(this.$route.params.returnTo)
})
},
onReset(evt) {
evt.preventDefault()
this.form.username = ''
this.form.password = ''
}
}
}
</script>
<style scoped>
</style>

View File

@ -27,14 +27,10 @@
slug: String slug: String
}, },
created() { created() {
this.save = _.debounce(this.save, 2000)
},
beforeUpdate() {
console.log('checking for slug: '+this.$props.slug)
if (this.$props.slug) { if (this.$props.slug) {
console.log('found slug: '+this.$props.slug)
this.getFile(this.$props.slug) this.getFile(this.$props.slug)
} }
this.save = _.debounce(this.save, 2000)
}, },
computed: { computed: {
tags: function () { tags: function () {

View File

@ -4,6 +4,7 @@ import router from './router'
import store from './store' import store from './store'
import BootstrapVue from 'bootstrap-vue' import BootstrapVue from 'bootstrap-vue'
import VueCookies from 'vue-cookies'
// import "bootswatch/dist/darkly/variables"; // import "bootswatch/dist/darkly/variables";
// import "bootstrap/scss/bootstrap"; // import "bootstrap/scss/bootstrap";
@ -15,6 +16,7 @@ import 'bootstrap-vue/dist/bootstrap-vue.css'
Vue.config.productionTip = false Vue.config.productionTip = false
Vue.use(BootstrapVue) Vue.use(BootstrapVue)
Vue.use(VueCookies)
new Vue({ new Vue({
router, router,

View File

@ -1,6 +1,7 @@
import Vue from 'vue' import Vue from 'vue'
import VueRouter from 'vue-router' import VueRouter from 'vue-router'
import Home from '../views/Home.vue' import Home from '../views/Home.vue'
import Login from '../views/Login.vue'
import Console from '../views/Console.vue' import Console from '../views/Console.vue'
Vue.use(VueRouter) Vue.use(VueRouter)
@ -31,6 +32,11 @@ const routes = [
name: 'console', name: 'console',
component: Console component: Console
}, },
{
path: '/login',
name: 'login',
component: Login
},
{ {
path: '/about', path: '/about',
name: 'about', name: 'about',

View File

@ -23,7 +23,8 @@ export default new Vuex.Store({
errs: [], errs: [],
searchResults: [], searchResults: [],
query: null, query: null,
file: null file: null,
key: null
}, },
mutations: { mutations: {
clearError(state) { clearError(state) {
@ -46,11 +47,18 @@ export default new Vuex.Store({
}, },
setTags(state, tags) { setTags(state, tags) {
state.file.Tags = tags state.file.Tags = tags
},
setKey(state, key) {
axios.defaults.headers.common['X-Auth-Key'] = key
state.key = key
} }
}, },
actions: { actions: {
newFile: function({commit}) { newFile: function ({commit, state}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!state.key) {
return reject('not logged in')
}
axios.post('/v1/entries', {}) axios.post('/v1/entries', {})
.catch(err => { .catch(err => {
commit('addError', err) commit('addError', err)
@ -85,10 +93,14 @@ export default new Vuex.Store({
}) })
}, },
saveFile: function ({state}) { saveFile: function ({state}) {
if (!state.key)
return new Promise((resolve, reject) => { reject('not logged in') })
if (state.file) if (state.file)
return axios.put('/v1/entries/' + state.file.Slug, state.file) return axios.put('/v1/entries/' + state.file.Slug, state.file)
}, },
deleteBySlug: function({dispatch,commit}, slug) { deleteBySlug: function ({dispatch, commit, state}, slug) {
if (!state.key)
return new Promise((resolve, reject) => { reject('not logged in') })
axios.delete('/v1/entries/' + slug) axios.delete('/v1/entries/' + slug)
.catch(err => { .catch(err => {
commit('addError', err) commit('addError', err)
@ -96,8 +108,21 @@ export default new Vuex.Store({
.then(() => { .then(() => {
dispatch('updateSearch') dispatch('updateSearch')
}) })
},
login: function ({commit}, {username, password}) {
return new Promise((resolve, reject) => {
axios.post('/v1/auth', {username: username, password: password})
.then(res => {
commit('setKey', res.data.User.AuthKey)
commit('clearError')
resolve()
})
.catch(err => {
commit('addError', err)
reject(err)
})
})
} }
}, },
modules: { modules: {}
}
}) })

View File

@ -78,7 +78,11 @@ export default {
// called before the route that renders this component is confirmed. // called before the route that renders this component is confirmed.
// does NOT have access to `this` component instance, // does NOT have access to `this` component instance,
// because it has not been created yet when this guard is called! // because it has not been created yet when this guard is called!
next() next(vm => {
if (!vm.$store.state.key) {
vm.$router.push({name: "login", params: {returnTo: vm.$route.path}})
}
})
}, },
beforeRouteUpdate (to, from, next) { beforeRouteUpdate (to, from, next) {
// called when the route that renders this component has changed, // called when the route that renders this component has changed,

View File

@ -0,0 +1,30 @@
<template>
<b-container fluid>
<b-row><b-col>
<h1>Login</h1>
</b-col></b-row>
<b-row class="justify-content-md-center">
<b-col md="auto">
<Login />
</b-col>
</b-row>
</b-container>
</template>
<script>
import Login from '../components/Login.vue'
export default {
name: 'login',
components: {
Login
}
}
</script>
<style scoped>
h2 {
font-size: large;
}
</style>

File diff suppressed because it is too large Load Diff

10
go.mod
View File

@ -3,14 +3,18 @@ module code.chrissexton.org/cws/cabinet
go 1.13 go 1.13
require ( require (
github.com/gobuffalo/packr/v2 v2.7.1 github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gorilla/handlers v1.4.2 github.com/gorilla/handlers v1.4.2
github.com/gorilla/mux v1.7.3 github.com/gorilla/mux v1.7.3
github.com/jmoiron/sqlx v1.2.0 github.com/jmoiron/sqlx v1.2.0
github.com/kr/pretty v0.1.0 // indirect
github.com/mattn/go-sqlite3 v1.11.0 github.com/mattn/go-sqlite3 v1.11.0
github.com/rs/zerolog v1.16.0 github.com/rs/zerolog v1.16.0
github.com/speps/go-hashids v2.0.0+incompatible
github.com/stretchr/graceful v1.2.15 github.com/stretchr/graceful v1.2.15
golang.org/x/sys v0.0.0-20191029155521-f43be2a4598c // indirect github.com/stretchr/testify v1.4.0
golang.org/x/crypto v0.0.0-20191107222254-f4817d981bb6
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b // indirect
google.golang.org/appengine v1.6.5 // indirect google.golang.org/appengine v1.6.5 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v2 v2.2.7 // indirect
) )

75
go.sum
View File

@ -1,87 +1,64 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.7.1/go.mod h1:FurDp9+EDPE4aIUS3ZLyD+7/9fpx7YRt/ukY6jIHf0w=
github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q=
github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg=
github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mattn/go-sqlite3 v1.11.0 h1:LDdKkqtYlom37fkvqs8rMPFKAMe8+SgjbwZ6ex1/A/Q=
github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.11.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.4.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.16.0 h1:AaELmZdcJHT8m6oZ5py4213cdFK8XGXkB3dFdAQ+P7Q=
github.com/rs/zerolog v1.16.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= github.com/rs/zerolog v1.16.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/stretchr/graceful v1.2.15 h1:vmXbwPGfe8bI6KkgmHry/P1Pk63bM3TDcfi+5mh+VHg=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/stretchr/graceful v1.2.15/go.mod h1:IxdGAOTZueMKoBr3oJIzdeg5CCCXbHXfV44sLhfAXXI= github.com/stretchr/graceful v1.2.15/go.mod h1:IxdGAOTZueMKoBr3oJIzdeg5CCCXbHXfV44sLhfAXXI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191107222254-f4817d981bb6 h1:VsmCukA2gDdC3Mu6evOIT0QjLSQWiJIwzv1Bdj4jdzU=
golang.org/x/crypto v0.0.0-20191107222254-f4817d981bb6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191029155521-f43be2a4598c h1:S/FtSvpNLtFBgjTqcKsRpsa6aVsI6iztaz1bQd9BJwE=
golang.org/x/sys v0.0.0-20191029155521-f43be2a4598c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74 h1:4cFkmztxtMslUX2SctSl+blCyXfpzhGOy9LhKAqSMA4=
golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191004055002-72853e10c5a3/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

24
main.go
View File

@ -1,19 +1,18 @@
package main package main
import ( import (
"code.chrissexton.org/cws/cabinet/entry"
"flag" "flag"
"os" "os"
"code.chrissexton.org/cws/cabinet/db"
"code.chrissexton.org/cws/cabinet/web"
packr "github.com/gobuffalo/packr/v2"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"code.chrissexton.org/cws/cabinet/auth"
"code.chrissexton.org/cws/cabinet/db"
"code.chrissexton.org/cws/cabinet/entry"
"code.chrissexton.org/cws/cabinet/web"
_ "github.com/mattn/go-sqlite3"
) )
var ( var (
@ -22,6 +21,7 @@ var (
salt = flag.String("salt", "c4b1n3t", "salt for IDs") salt = flag.String("salt", "c4b1n3t", "salt for IDs")
minHashLen = flag.Int("minHashLen", 4, "minimum ID hash size") minHashLen = flag.Int("minHashLen", 4, "minimum ID hash size")
develop = flag.Bool("develop", false, "turn on develop mode") develop = flag.Bool("develop", false, "turn on develop mode")
adminSecret = flag.String("adminSecret", "helpme123", "secret for user creation")
) )
func main() { func main() {
@ -29,9 +29,6 @@ func main() {
log.Logger = log.Logger.Output(zerolog.ConsoleWriter{Out: os.Stderr}) log.Logger = log.Logger.Output(zerolog.ConsoleWriter{Out: os.Stderr})
zerolog.SetGlobalLevel(zerolog.DebugLevel) zerolog.SetGlobalLevel(zerolog.DebugLevel)
box := packr.New("dist", "frontend/dist")
log.Debug().Strs("dirlist", box.List()).Msg("packr made")
db, err := db.New(*dbPath) db, err := db.New(*dbPath)
if err != nil { if err != nil {
log.Fatal(). log.Fatal().
@ -43,8 +40,11 @@ func main() {
if err := entry.PrepareTable(tx); err != nil { if err := entry.PrepareTable(tx); err != nil {
log.Fatal().Err(err).Msg("could not create database") log.Fatal().Err(err).Msg("could not create database")
} }
if err = auth.PrepareTable(tx); err != nil {
log.Fatal().Err(err).Msg("could not create database")
}
tx.Commit() tx.Commit()
s := web.New(*httpAddr, db, box) s := web.New(*httpAddr, db, FS(*develop))
s.Serve() s.Serve()
} }

105
web/auth.go Normal file
View File

@ -0,0 +1,105 @@
package web
import (
"encoding/json"
"fmt"
"net/http"
"code.chrissexton.org/cws/cabinet/auth"
"code.chrissexton.org/cws/cabinet/config"
"github.com/rs/zerolog/log"
)
func (web *Web) auth(w http.ResponseWriter, r *http.Request) {
req := struct {
Username string
Password string
}{}
dec := json.NewDecoder(r.Body)
err := dec.Decode(&req)
if err != nil {
log.Error().Err(err).Msg("Error decoding json request")
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
user, err := auth.Get(web.db, req.Username)
if err != nil {
w.WriteHeader(401)
resp := struct {
Status bool
Err string
}{
false,
"User and password combination is invalid",
}
j, err := json.Marshal(resp)
if err != nil {
w.WriteHeader(500)
log.Error().Err(err).Msg("Error encoding json response")
return
}
w.Write(j)
return
}
if user.Validate(req.Password) {
resp := struct {
Status bool
User auth.User
}{
true,
*user,
}
j, err := json.Marshal(resp)
if err != nil {
w.WriteHeader(500)
log.Error().Err(err).Msg("Error encoding json response")
return
}
w.WriteHeader(200)
w.Write(j)
return
}
w.WriteHeader(401)
resp := struct {
Status bool
Message string
}{Message: "incorrect credentials"}
j, err := json.Marshal(resp)
if err != nil {
w.WriteHeader(500)
log.Error().Err(err).Msg("Error encoding json response")
return
}
w.Write(j)
}
func (web *Web) newUser(w http.ResponseWriter, r *http.Request) {
secret := r.Header.Get("X-secret")
if secret != config.Get("secret", "abc123") {
w.WriteHeader(401)
return
}
dec := json.NewDecoder(r.Body)
req := struct {
Username string
Password string
}{}
err := dec.Decode(&req)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
_, err = auth.New(web.db, req.Username, req.Password)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
log.Error().Err(err).Msg("Could not create user")
return
}
w.WriteHeader(200)
}

View File

@ -13,6 +13,18 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
func (web *Web) writeJSON(w http.ResponseWriter, code int, data interface{}) error {
w.Header().Set("content-type", "application/json")
resp, err := json.Marshal(data)
if err != nil {
w.WriteHeader(500)
return err
}
w.WriteHeader(code)
w.Write(resp)
return nil
}
func (web *Web) editEntry(w http.ResponseWriter, r *http.Request) { func (web *Web) editEntry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
slug := vars["slug"] slug := vars["slug"]
@ -43,93 +55,87 @@ func (web *Web) editEntry(w http.ResponseWriter, r *http.Request) {
err = oldEntry.Update() err = oldEntry.Update()
if err != nil { if err != nil {
w.WriteHeader(500) web.writeJSON(w, 500, err)
fmt.Fprint(w, err)
return return
} }
resp, err := json.Marshal(oldEntry) web.writeJSON(w, 200, oldEntry)
}
func (web *Web) newAdocEntry(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
if err != nil { if err != nil {
w.WriteHeader(500) web.writeJSON(w, 500, err)
fmt.Fprint(w, err) return
}
newEntry := entry.NewFromAdoc(web.db, string(body))
err = newEntry.Create()
if err != nil {
web.writeJSON(w, 500, err)
return return
} }
w.Header().Set("content-type", "application/json") web.writeJSON(w, 200, newEntry)
fmt.Fprint(w, string(resp))
} }
func (web *Web) newMarkdownEntry(w http.ResponseWriter, r *http.Request) { func (web *Web) newMarkdownEntry(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body) body, err := ioutil.ReadAll(r.Body)
if err != nil { if err != nil {
w.WriteHeader(500) log.Error().Err(err).Msgf("could not read message body")
fmt.Fprint(w, err) web.writeJSON(w, 500, err)
return return
} }
newEntry := entry.NewFromMd(web.db, string(body)) newEntry := entry.NewFromMd(web.db, string(body))
err = newEntry.Create() err = newEntry.Create()
if err != nil { if err != nil {
w.WriteHeader(500) log.Error().Err(err).Msgf("could not create entry")
fmt.Fprint(w, err) web.writeJSON(w, 500, err)
return return
} }
resp, err := json.Marshal(newEntry) web.writeJSON(w, 200, newEntry)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
w.Header().Set("content-type", "application/json")
fmt.Fprint(w, string(resp))
} }
func (web *Web) newEntry(w http.ResponseWriter, r *http.Request) { func (web *Web) newEntry(w http.ResponseWriter, r *http.Request) {
log.Debug().
Str("content-type", r.Header.Get("Content-Type")).
Msgf("newEntry")
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
newEntry := entry.New(web.db) newEntry := entry.New(web.db)
err := dec.Decode(&newEntry) err := dec.Decode(&newEntry)
if err != nil { if err != nil {
w.WriteHeader(500) log.Error().Err(err).Msgf("could not decode entry")
fmt.Fprint(w, err) web.writeJSON(w, 500, err)
return return
} }
err = newEntry.Create() err = newEntry.Create()
if err != nil { if err != nil {
w.WriteHeader(500) log.Error().Err(err).Msgf("could not create raw entry")
fmt.Fprint(w, err) web.writeJSON(w, 500, err)
return return
} }
resp, err := json.Marshal(newEntry)
if err != nil { web.writeJSON(w, 200, newEntry)
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
w.Header().Set("content-type", "application/json")
fmt.Fprint(w, string(resp))
} }
func (web *Web) allEntries(w http.ResponseWriter, r *http.Request) { func (web *Web) allEntries(w http.ResponseWriter, r *http.Request) {
query := "" query := ""
tags := []string{}
if !web.AuthCheck(r) {
tags = append(tags, "public")
}
items, ok := r.URL.Query()["query"] items, ok := r.URL.Query()["query"]
if ok { if ok {
query = items[0] query = items[0]
} }
entries, err := entry.Search(web.db, query) entries, err := entry.SearchByTag(web.db, query, tags)
if err != nil { if err != nil {
w.WriteHeader(500) log.Error().Msgf("Error querying: %w", err)
fmt.Fprint(w, err) web.writeJSON(w, 500, err)
return return
} }
resp, err := json.Marshal(entries) web.writeJSON(w, 200, entries)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
w.Header().Set("content-type", "application/json")
fmt.Fprint(w, string(resp))
} }
func (web *Web) getEntry(w http.ResponseWriter, r *http.Request) { func (web *Web) getEntry(w http.ResponseWriter, r *http.Request) {
@ -138,19 +144,16 @@ func (web *Web) getEntry(w http.ResponseWriter, r *http.Request) {
entry, err := entry.GetBySlug(web.db, slug) entry, err := entry.GetBySlug(web.db, slug)
if err != nil { if err != nil {
w.WriteHeader(500) web.writeJSON(w, 500, err)
fmt.Fprint(w, err)
return return
} }
resp, err := json.Marshal(entry) if !web.AuthCheck(r) && !entry.HasTag("public") {
if err != nil { web.writeJSON(w, 401, "not authorized")
w.WriteHeader(500)
fmt.Fprint(w, err)
return return
} }
w.Header().Set("content-type", "application/json")
fmt.Fprint(w, string(resp)) web.writeJSON(w, 200, entry)
} }
func (web *Web) removeEntry(w http.ResponseWriter, r *http.Request) { func (web *Web) removeEntry(w http.ResponseWriter, r *http.Request) {
@ -160,9 +163,8 @@ func (web *Web) removeEntry(w http.ResponseWriter, r *http.Request) {
err := entry.RemoveBySlug(web.db, slug) err := entry.RemoveBySlug(web.db, slug)
if err != nil { if err != nil {
log.Error().Msgf("Error deleting: %s", err) log.Error().Msgf("Error deleting: %s", err)
w.WriteHeader(500) web.writeJSON(w, 500, err)
fmt.Fprint(w, err)
return return
} }
w.WriteHeader(200) web.writeJSON(w, 200, "success")
} }

View File

@ -1,19 +1,18 @@
package web package web
import ( import (
"fmt"
"net/http" "net/http"
"os" "os"
"time" "time"
"code.chrissexton.org/cws/cabinet/db"
packr "github.com/gobuffalo/packr/v2"
"github.com/speps/go-hashids"
"github.com/gorilla/handlers" "github.com/gorilla/handlers"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"code.chrissexton.org/cws/cabinet/auth"
"code.chrissexton.org/cws/cabinet/db"
"github.com/stretchr/graceful" "github.com/stretchr/graceful"
) )
@ -21,15 +20,14 @@ type Web struct {
addr string addr string
db *db.Database db *db.Database
salt string salt string
h *hashids.HashID static http.FileSystem
box *packr.Box
} }
func New(addr string, db *db.Database, box *packr.Box) *Web { func New(addr string, db *db.Database, static http.FileSystem) *Web {
w := &Web{ w := &Web{
addr: addr, addr: addr,
db: db, db: db,
box: box, static: static,
} }
if err := db.MakeDB(); err != nil { if err := db.MakeDB(); err != nil {
log.Fatal(). log.Fatal().
@ -39,9 +37,46 @@ func New(addr string, db *db.Database, box *packr.Box) *Web {
return w return w
} }
type AuthMiddleware struct {
web *Web
db *db.Database
}
func NewAuthMiddleware(web *Web) AuthMiddleware {
return AuthMiddleware{
web: web,
db: web.db,
}
}
func (aw *AuthMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if aw.web.AuthCheck(r) {
next.ServeHTTP(w, r)
return
}
w.WriteHeader(401)
fmt.Fprint(w, "invalid login")
})
}
func (web *Web) AuthCheck(r *http.Request) bool {
key := r.Header.Get("X-Auth-Key")
u, err := auth.GetByKey(web.db, key)
if key == "" || err != nil {
return false
}
log.Debug().Msgf("This shit is authed to user %s!", u.Name)
return true
}
func (web *Web) routeSetup() http.Handler { func (web *Web) routeSetup() http.Handler {
r := mux.NewRouter() r := mux.NewRouter()
api := r.PathPrefix("/v1/").Subrouter() api := r.PathPrefix("/v1/").Subrouter()
auth := NewAuthMiddleware(web)
authedApi := r.PathPrefix("/v1/").Subrouter()
authedApi.Use(auth.Middleware)
api.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) { api.HandleFunc("/test", func(w http.ResponseWriter, r *http.Request) {
log.Debug().Msg("test json") log.Debug().Msg("test json")
@ -53,15 +88,20 @@ func (web *Web) routeSetup() http.Handler {
// curl 'http://127.0.0.1:8080/v1/test' -X POST -H 'Accept: application/json, text/plain, */*' --compressed -H 'Content-Type: application/json;charset=utf-8' --data '{ "test": 1 }' // curl 'http://127.0.0.1:8080/v1/test' -X POST -H 'Accept: application/json, text/plain, */*' --compressed -H 'Content-Type: application/json;charset=utf-8' --data '{ "test": 1 }'
api.HandleFunc("/entries", web.allEntries).Methods(http.MethodGet) authedApi.HandleFunc("/entries", web.newEntry).Methods(http.MethodPost).
api.HandleFunc("/entries", web.newEntry).Methods(http.MethodPost)
api.HandleFunc("/entries", web.newEntry).Methods(http.MethodPost).
HeadersRegexp("Content-Type", "application/(text|json).*") HeadersRegexp("Content-Type", "application/(text|json).*")
api.HandleFunc("/entries", web.newMarkdownEntry).Methods(http.MethodPost). authedApi.HandleFunc("/entries", web.newMarkdownEntry).Methods(http.MethodPost).
HeadersRegexp("Content-Type", "application/markdown.*") HeadersRegexp("Content-Type", "application/markdown")
api.HandleFunc("/entries/{slug}", web.removeEntry).Methods(http.MethodDelete) authedApi.HandleFunc("/entries", web.newAdocEntry).Methods(http.MethodPost).
api.HandleFunc("/entries/{slug}", web.editEntry).Methods(http.MethodPut) HeadersRegexp("Content-Type", "application/asciidoc")
authedApi.HandleFunc("/entries/{slug}", web.removeEntry).Methods(http.MethodDelete)
authedApi.HandleFunc("/entries/{slug}", web.editEntry).Methods(http.MethodPut)
api.HandleFunc("/entries/{slug}", web.getEntry).Methods(http.MethodGet) api.HandleFunc("/entries/{slug}", web.getEntry).Methods(http.MethodGet)
api.HandleFunc("/entries", web.allEntries).Methods(http.MethodGet)
api.HandleFunc("/auth/new", web.newUser).Methods(http.MethodPost)
api.HandleFunc("/auth", web.auth).Methods(http.MethodPost)
r.PathPrefix("/").HandlerFunc(web.indexHandler("/index.html")) r.PathPrefix("/").HandlerFunc(web.indexHandler("/index.html"))
loggedRouter := handlers.LoggingHandler(os.Stdout, r) loggedRouter := handlers.LoggingHandler(os.Stdout, r)
return loggedRouter return loggedRouter

View File

@ -1,54 +1,60 @@
package web package web
import ( import (
"io/ioutil"
"mime" "mime"
"net/http" "net/http"
"os"
"path" "path"
"path/filepath" "path/filepath"
"strings"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
func (web *Web) indexHandler(entryPoint string) func(w http.ResponseWriter, r *http.Request) { func (web *Web) indexHandler(entryPoint string) func(w http.ResponseWriter, r *http.Request) {
entryPoint = path.Clean(strings.TrimPrefix(entryPoint, "/")) entryPoint = path.Join(path.Clean(entryPoint))
fn := func(w http.ResponseWriter, r *http.Request) { fn := func(w http.ResponseWriter, r *http.Request) {
p := path.Clean(strings.TrimPrefix(r.URL.Path, "/")) p := path.Join(path.Clean(r.URL.Path))
log.Debug().Str("path", p).Msg("requested path") log.Debug().Str("path", p).Msg("requested path")
if web.box.Has(p) && !web.box.HasDir(p) { var info os.FileInfo
f, err := web.box.Find(p) f, err := web.static.Open(p)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Error finding file") log.Error().Err(err).Msg("Error finding file")
w.WriteHeader(http.StatusNotFound) goto entryPoint
} }
write(w, f, p) defer f.Close()
return info, err = f.Stat()
}
if web.box.HasDir(p) && web.box.Has(path.Join(p, "index.html")) {
f, err := web.box.Find(path.Join(p, "index.html"))
if err != nil { if err != nil {
log.Error().Err(err).Msg("Error finding file") log.Error().Err(err).Msg("Error finding file")
w.WriteHeader(http.StatusNotFound) goto entryPoint
} }
write(w, f, p) if info.IsDir() {
return if f, err := web.static.Open(path.Join(p, "index.html")); err == nil && f != nil {
}
log.Debug().Str("path", p).Str("entry", entryPoint).Msg("all handlers fell through, giving default")
if f, err := web.box.Find(entryPoint); err == nil {
write(w, f, p) write(w, f, p)
return return
} else { } else {
log.Error().AnErr("err", err).Msgf("could not load any files %s", err) log.Error().Msgf("Could not load file %s: %w", path.Join(p, "index.html"), err)
}
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
} else {
write(w, f, p)
return
}
entryPoint:
if f, err := web.static.Open(entryPoint); err == nil {
write(w, f, p)
return
} else {
log.Error().Msgf("Could not load file %s or %s", p, entryPoint)
w.WriteHeader(http.StatusNotFound)
}
}
return fn return fn
} }
func write(w http.ResponseWriter, f []byte, path string) { func write(w http.ResponseWriter, file http.File, path string) {
f, _ := ioutil.ReadAll(file)
ctype := mime.TypeByExtension(filepath.Ext(path)) ctype := mime.TypeByExtension(filepath.Ext(path))
if ctype == "" { if ctype == "" {
ctype = http.DetectContentType(f) ctype = http.DetectContentType(f)