Compare commits

..

10 Commits

Author SHA1 Message Date
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
cws 2fdf9ce7e6 Merge pull request 'slug: create slug from md every time' (#15) from fix_slug_bug into master
Reviewed-on: #15
2020-02-12 22:43:59 +00:00
Chris Sexton c43f8c18ca slug: create slug from md every time
Fixes #13
2020-02-12 17:42:03 -05:00
Chris Sexton def6e6f24f add dockerfile
fixes #10
2019-11-17 11:51:44 -05:00
Chris Sexton e80b138f67 fix a few package issues for docker build
refs #10
2019-11-17 09:48:40 -05:00
Chris Sexton 6b350ef201 remove todo file
fixes #11
2019-11-17 09:27:28 -05:00
Chris Sexton 3c39830c4d update todo 2019-11-16 08:56:00 -05:00
Chris Sexton 0d32edbe0e update todo 2019-11-16 08:52:07 -05:00
Chris Sexton 723f628c31 frontend theming; md content-types 2019-11-16 08:49:07 -05:00
20 changed files with 2491 additions and 2451 deletions

View File

@ -1,9 +1,17 @@
package auth package auth
import ( import (
"code.chrissexton.org/cws/cabinet/db" "crypto/rand"
"encoding/hex"
"errors"
"time"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/rs/zerolog/log"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"code.chrissexton.org/cws/cabinet/config"
"code.chrissexton.org/cws/cabinet/db"
) )
type User struct { type User struct {
@ -12,21 +20,44 @@ type User struct {
ID int64 ID int64
Name string Name string
Hash []byte Hash []byte
AuthKey string `db:"auth_key"`
Invalidate time.Time
} }
func PrepareTable(tx *sqlx.Tx) error { func PrepareTable(tx *sqlx.Tx) error {
q := `create table if not exists users ( q := `create table if not exists users (
id integer primary key, id integer primary key,
name text unique, name text unique not null,
hash text hash text not null,
auth_key text,
invalidate datetime
)` )`
_, err := tx.Exec(q) _, err := tx.Exec(q)
return err 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) { func New(db *db.Database, name, password string) (*User, error) {
q := `insert into users (null, ?, ?)` q := `insert into users values (null, ?, ?, ?, ?)`
res, err := db.Exec(q, name, password)
key, err := makeKey()
if err != nil {
return nil, err
}
invalidate := time.Now().Add(time.Duration(config.GetInt("invalidate.hours", 7*24)) * time.Hour)
res, err := db.Exec(q, name, password, key, invalidate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -37,6 +68,8 @@ func New(db *db.Database, name, password string) (*User, error) {
u := &User{ u := &User{
ID: id, ID: id,
Name: name, Name: name,
AuthKey: key,
Invalidate: invalidate,
} }
u.Set(password) u.Set(password)
return u, nil return u, nil
@ -67,3 +100,19 @@ func (u *User) Validate(password string) bool {
} }
return true 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
} }

34
docker/Dockerfile Normal file
View File

@ -0,0 +1,34 @@
FROM alpine:edge
RUN apk add --no-cache git
RUN apk add --no-cache musl-dev
RUN apk add --no-cache gcc
RUN apk add --no-cache sqlite
RUN apk add --no-cache go
RUN apk add --no-cache make
RUN apk add --no-cache npm
RUN apk add --no-cache yarn
VOLUME /app/var
EXPOSE 5673
ARG gomaxprocs="8"
WORKDIR /app
ENV SRC_DIR=/app/src/
ENV GOMAXPROCS=${gomaxprocs}
RUN git clone https://code.chrissexton.org/cws/cabinet.git $SRC_DIR
RUN apk add --no-cache tzdata
ENV TZ America/New_York
# RUN yarn global add @vue/cli
RUN cd $SRC_DIR/frontend; yarn && yarn build
RUN go get -u github.com/gobuffalo/packr/v2/packr2
RUN cd $SRC_DIR; $HOME/go/bin/packr2
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"]

View File

@ -52,6 +52,14 @@ func PrepareTable(tx *sqlx.Tx) error {
return nil return nil
} }
func NewFromMd(db *db.Database, body string) *Entry {
e := New(db)
e.Content = body
e.Title = e.GenerateTitle()
e.Slug = e.UniqueSlug()
return e
}
func New(db *db.Database) *Entry { func New(db *db.Database) *Entry {
e := Entry{ e := Entry{
db: db, db: db,

View File

@ -11,8 +11,11 @@
"@vue/cli": "^4.0.5", "@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",
"brace": "latest", "brace": "latest",
"core-js": "^3.3.2", "core-js": "^3.3.2",
"lodash": "^4.17.15", "lodash": "^4.17.15",
@ -30,6 +33,9 @@
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"eslint": "^5.16.0", "eslint": "^5.16.0",
"eslint-plugin-vue": "^5.0.0", "eslint-plugin-vue": "^5.0.0",
"sass": "^1.23.0",
"sass-loader": "^8.0.0",
"webpack": "^4.36.0",
"vue-template-compiler": "^2.6.10" "vue-template-compiler": "^2.6.10"
} }
} }

View File

@ -5,7 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>frontend</title> <title>Cabinet</title>
</head> </head>
<body> <body>
<noscript> <noscript>

View File

@ -1,6 +1,6 @@
<template> <template>
<div id="app"> <div id="app">
<b-navbar type="dark" variant="dark"> <b-navbar type="dark" variant="primary" class="navbar">
<b-navbar-brand>🗄 Cabinet</b-navbar-brand> <b-navbar-brand>🗄 Cabinet</b-navbar-brand>
<b-navbar-nav> <b-navbar-nav>
<b-nav-item to="/">Home</b-nav-item> <b-nav-item to="/">Home</b-nav-item>
@ -18,26 +18,11 @@
</div> </div>
</template> </template>
<style> <style lang="scss">
#app { .navbar {
font-family: 'Avenir', Helvetica, Arial, sans-serif; padding: 0.5em !important;
-webkit-font-smoothing: antialiased; margin-bottom: 1em;
-moz-osx-font-smoothing: grayscale; }
color: #2c3e50;
}
#nav {
padding: 30px;
}
#nav a {
font-weight: bold;
color: #2c3e50;
}
#nav a.router-link-exact-active {
color: #42b983;
}
</style> </style>
<script> <script>

View File

@ -1,6 +1,13 @@
<template> <template>
<div> <div>
<editor ref="myEditor" v-model="text" @init="editorInit" lang="asciidoc" theme="github" width="100%" height="500" /> <editor
ref="myEditor"
v-model="text"
@init="editorInit"
lang="asciidoc"
theme="tomorrow_night"
width="100%"
height="500" />
</div> </div>
</template> </template>
@ -32,7 +39,7 @@
editorInit: function () { editorInit: function () {
require('brace/ext/language_tools') //language extension prerequsite... require('brace/ext/language_tools') //language extension prerequsite...
require('brace/mode/asciidoc') //language require('brace/mode/asciidoc') //language
require('brace/theme/github') require('brace/theme/tomorrow_night')
} }
} }
} }

View File

@ -75,7 +75,7 @@
.then(res => { .then(res => {
this.$emit('markDirty', false) this.$emit('markDirty', false)
this.$store.dispatch('updateSearch') this.$store.dispatch('updateSearch')
if (res.data.Slug != this.$route.params.slug) if (res.data.Slug !== this.$route.params.slug)
this.$router.replace({params: { slug: res.data.Slug }}) this.$router.replace({params: { slug: res.data.Slug }})
}) })
.catch(() => { }) .catch(() => { })

View File

@ -1,22 +1,15 @@
<template> <template>
<div :hidden="!content"> <div :hidden="!content">
<b-container fluid>
<b-row>
<b-col>
<Viewer :content="content" /> <Viewer :content="content" />
</b-col> <b-card-group :hidden="!tags">
</b-row> <b-card
<b-row> style="max-width: 50%"
<b-col cols="10"> header="Tags"
<label for="tagList" :hidden="!tags">Tags</label> header-tag="header"
</b-col> >
</b-row>
<b-row>
<b-col cols="10">
<TagList id="tagList" :tags="tags" :readOnly="true" /> <TagList id="tagList" :tags="tags" :readOnly="true" />
</b-col> </b-card>
</b-row> </b-card-group>
</b-container>
</div> </div>
</template> </template>

View File

@ -1,11 +1,15 @@
<template> <template>
<b-container fluid> <b-container fluid>
<b-row> <b-row>
<b-col class="searchBox">
<b-input placeholder="Search" @update="getResults" v-model="queryText" /> <b-input placeholder="Search" @update="getResults" v-model="queryText" />
</b-col>
</b-row> </b-row>
<b-row v-for="item in results" v-bind:key="item.ID"> <b-row v-for="item in results" v-bind:key="item.ID">
<b-col> <b-col>
<b-button :hidden="!editMode" size="sm" class="deleteLink" @click="deleteFile(item.Slug)">X</b-button> <b-link <b-button :hidden="!editMode" size="sm" class="deleteLink" @click="deleteFile(item.Slug)">X</b-button>
<b-link
class="searchLink"
:to="{ name: target, params: { slug: item.Slug } }" :to="{ name: target, params: { slug: item.Slug } }"
>{{item.Title}}</b-link> >{{item.Title}}</b-link>
</b-col> </b-col>
@ -59,4 +63,11 @@
.deleteLink { .deleteLink {
font-size: x-small; font-size: x-small;
} }
.searchBox {
margin-bottom: 1em;
}
.searchLink {
margin-left: 1em;
color: var(--info);
}
</style> </style>

View File

@ -5,7 +5,11 @@ import store from './store'
import BootstrapVue from 'bootstrap-vue' import BootstrapVue from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css' // import "bootswatch/dist/darkly/variables";
// import "bootstrap/scss/bootstrap";
import "bootswatch/dist/darkly/bootstrap.css";
// import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css' import 'bootstrap-vue/dist/bootstrap-vue.css'
Vue.config.productionTip = false Vue.config.productionTip = false

View File

@ -2,9 +2,18 @@
<b-container fluid> <b-container fluid>
<b-row> <b-row>
<b-col md="5"> <b-col md="5">
<h2>Scratchpad</h2> <div>
<b-tabs content-class="mt-3">
<b-tab active>
<template v-slot:title>
Scratchpad
</template>
<ScratchPad /> <ScratchPad />
</b-tab>
</b-tabs>
</div>
</b-col> </b-col>
<b-col md="5"> <b-col md="5">
<div> <div>
@ -24,8 +33,16 @@
</b-col> </b-col>
<b-col md="2"> <b-col md="2">
<h2>Search Results</h2> <div>
<b-tabs content-class="mt-3">
<b-tab active>
<template v-slot:title>
Search
</template>
<SearchResults :editMode="true" target="console-slug" /> <SearchResults :editMode="true" target="console-slug" />
</b-tab>
</b-tabs>
</div>
</b-col> </b-col>
</b-row> </b-row>
</b-container> </b-container>

File diff suppressed because it is too large Load Diff

1
go.mod
View File

@ -11,6 +11,7 @@ require (
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/speps/go-hashids v2.0.0+incompatible
github.com/stretchr/graceful v1.2.15 github.com/stretchr/graceful v1.2.15
github.com/stretchr/testify v1.4.0
golang.org/x/crypto v0.0.0-20191107222254-f4817d981bb6 golang.org/x/crypto v0.0.0-20191107222254-f4817d981bb6
golang.org/x/sys v0.0.0-20191029155521-f43be2a4598c // indirect golang.org/x/sys v0.0.0-20191029155521-f43be2a4598c // indirect
google.golang.org/appengine v1.6.5 // indirect google.golang.org/appengine v1.6.5 // indirect

View File

@ -1,20 +0,0 @@
= Todo
:icons: font
* Backend
** Authentication
*** [ ] some kind of user auth
** save endpoint
*** [ ] add authentication/authorization
*** [ ] convert document to adoc (give format?)
*** [ ] check for unique tags
** [ ] search endpoint
*** [ ] search for tags
*** [ ] fulltext search
**** with link:https://blevesearch.com/docs/Getting%20Started/[Bleve]
* [ ] CLI Frontend
* [ ] Operations
** [ ] dockerize the build
** [ ] integrate CI/CD
** [ ] run on https://cabinet.chrissexton.org[cabinet.chrissexton.org]
** [ ] create redirect or https://cab.chrissexton.org[cab.chrissexton.org]

View File

@ -3,6 +3,7 @@ package web
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"time" "time"
@ -16,13 +17,16 @@ func (web *Web) editEntry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r) vars := mux.Vars(r)
slug := vars["slug"] slug := vars["slug"]
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
newEntry := entry.New(web.db) req := struct {
err := dec.Decode(&newEntry) Content string
}{}
err := dec.Decode(&req)
if err != nil { if err != nil {
w.WriteHeader(500) w.WriteHeader(500)
fmt.Fprint(w, err) fmt.Fprint(w, err)
return return
} }
newEntry := entry.NewFromMd(web.db, req.Content)
oldEntry, err := entry.GetBySlug(web.db, slug) oldEntry, err := entry.GetBySlug(web.db, slug)
if err != nil { if err != nil {
@ -32,6 +36,8 @@ func (web *Web) editEntry(w http.ResponseWriter, r *http.Request) {
} }
oldEntry.Content = newEntry.Content oldEntry.Content = newEntry.Content
oldEntry.Title = newEntry.Title
oldEntry.Slug = newEntry.UniqueSlug()
oldEntry.Tags = newEntry.Tags oldEntry.Tags = newEntry.Tags
oldEntry.Updated = time.Now() oldEntry.Updated = time.Now()
@ -48,8 +54,31 @@ func (web *Web) editEntry(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Debug().Interface("oldEntry", oldEntry).Msg("Got a PUT") w.Header().Set("content-type", "application/json")
fmt.Fprint(w, string(resp))
}
func (web *Web) newMarkdownEntry(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
newEntry := entry.NewFromMd(web.db, string(body))
err = newEntry.Create()
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
resp, err := json.Marshal(newEntry)
if err != nil {
w.WriteHeader(500)
fmt.Fprint(w, err)
return
}
w.Header().Set("content-type", "application/json") w.Header().Set("content-type", "application/json")
fmt.Fprint(w, string(resp)) fmt.Fprint(w, string(resp))
} }

View File

@ -70,13 +70,13 @@ 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) api.HandleFunc("/entries", web.allEntries).Methods(http.MethodGet)
authedApi.HandleFunc("/entries", web.newEntry).Methods(http.MethodPost) api.HandleFunc("/entries", web.newEntry).Methods(http.MethodPost)
authedApi.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).*")
//authedApi.HandleFunc("/entries", web.newMarkdownEntry).Methods(http.MethodPost). api.HandleFunc("/entries", web.newMarkdownEntry).Methods(http.MethodPost).
// HeadersRegexp("Content-Type", "application/markdown.*") HeadersRegexp("Content-Type", "application/markdown.*")
authedApi.HandleFunc("/entries/{slug}", web.removeEntry).Methods(http.MethodDelete) api.HandleFunc("/entries/{slug}", web.removeEntry).Methods(http.MethodDelete)
authedApi.HandleFunc("/entries/{slug}", web.editEntry).Methods(http.MethodPut) api.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("/auth", web.auth).Methods(http.MethodPost) api.HandleFunc("/auth", web.auth).Methods(http.MethodPost)