aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--go.mod8
-rw-r--r--go.sum16
-rw-r--r--index.tpl32
-rw-r--r--login.go1
-rw-r--r--main.go444
-rw-r--r--static/style.css (renamed from style.css)0
-rw-r--r--templates/base.tpl (renamed from error.tpl)15
-rw-r--r--templates/error.tpl11
-rw-r--r--templates/index.tpl60
-rw-r--r--templates/view.tpl9
-rw-r--r--view.tpl21
11 files changed, 504 insertions, 113 deletions
diff --git a/go.mod b/go.mod
index f3a6dab..3d92265 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,10 @@ module riedstra.dev/mitch/bpaste
go 1.16
-require github.com/gorilla/mux v1.8.0 // indirect
+require (
+ github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
+ github.com/golang-jwt/jwt/v4 v4.0.0 // indirect
+ github.com/gorilla/mux v1.8.0 // indirect
+ golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
+ gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
+)
diff --git a/go.sum b/go.sum
index 5350288..fbde530 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,18 @@
+github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
+github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
+github.com/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o=
+github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
+golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
+gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/index.tpl b/index.tpl
deleted file mode 100644
index a117af9..0000000
--- a/index.tpl
+++ /dev/null
@@ -1,32 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <link rel="stylesheet" href="/style.css" defer>
- <title>Brutally Simple Pastebin</title>
-</head>
-<body>
-<nav>
- <a href="/">Home</a>
- <div style="display: block; float: right;">
- </div>
-</nav>
-
-<container>
- <h1>Brutally simple pastebin</h1>
-
- <form action="/new" method="POST" id="postform">
- <label for="title">Title:</label>
- <input id="title" name="title" type="text" />
-
- <label for="content">Paste Content:</label>
-
- <input type="submit" value="Post">
- </form>
-
- <textarea id="content" name="content" cols="80" rows="24" form="postform"></textarea>
-</container>
-
-
-</body>
-</html>
diff --git a/login.go b/login.go
new file mode 100644
index 0000000..06ab7d0
--- /dev/null
+++ b/login.go
@@ -0,0 +1 @@
+package main
diff --git a/main.go b/main.go
index ee53f7e..9040a70 100644
--- a/main.go
+++ b/main.go
@@ -1,46 +1,59 @@
package main
import (
- "compress/gzip"
+ "crypto/ed25519"
"crypto/rand"
- _ "embed"
+ "embed"
"encoding/base64"
- "encoding/gob"
+ "encoding/json"
+ "encoding/pem"
"errors"
"flag"
+ "fmt"
"html/template"
+ "io"
"log"
"net/http"
"os"
+ "path/filepath"
+ "strings"
"time"
+ "github.com/golang-jwt/jwt/v4"
"github.com/gorilla/mux"
+ "golang.org/x/crypto/bcrypt"
+ "gopkg.in/yaml.v3"
)
var logger = log.New(os.Stderr, "", 0)
-// const ID_BYTES = 5
-const ID_BYTES = 16
+var ID_BYTES = 8
-//go:embed index.tpl
-var indexTemplateContent string
-var indexTemplate = template.Must(template.New("index").Parse(indexTemplateContent))
+//go:embed templates/*
+var templateFS embed.FS
-//go:embed view.tpl
-var viewTemplateContent string
-var viewTemplate = template.Must(template.New("view").Parse(viewTemplateContent))
+var tpls = map[string]*template.Template{}
-//go:embed error.tpl
-var errorTemplateContent string
-var errorTemplate = template.Must(template.New("error").Parse(errorTemplateContent))
+func init() {
+ // List of templates that will be pre-rendered and toss into
+ // the tpls map above
+ tplList := []string{"index", "view", "error"}
+ for _, tpl := range tplList {
+ tpls[tpl] = template.Must(template.ParseFS(templateFS,
+ "templates/"+tpl+".tpl",
+ "templates/base.tpl",
+ ))
+ }
+}
-//go:embed style.css
-var stylesheetContent []byte
+//go:embed static/*
+var staticFS embed.FS
type Paste struct {
Id string
Title string
- Content []byte
+ Tags map[string]struct{}
+ Content string
}
func (p Paste) GetContent() string {
@@ -48,43 +61,33 @@ func (p Paste) GetContent() string {
}
func (p *Paste) Load() error {
- fh, err := os.Open(p.Id)
- if err != nil {
- return err
- }
-
- zrdr, err := gzip.NewReader(fh)
+ fh, err := os.Open(filepath.Join("p", p.Id))
if err != nil {
return err
}
- dec := gob.NewDecoder(zrdr)
+ dec := json.NewDecoder(fh)
err = dec.Decode(&p)
if err != nil {
return err
}
- zrdr.Close()
-
return fh.Close()
}
func (p Paste) Save() error {
- fh, err := os.OpenFile(p.Id, os.O_CREATE|os.O_RDWR, 0666)
+ fh, err := os.OpenFile(filepath.Join("p", p.Id), os.O_CREATE|os.O_RDWR, 0666)
if err != nil {
return err
}
- zwr := gzip.NewWriter(fh)
-
- enc := gob.NewEncoder(zwr)
+ enc := json.NewEncoder(fh)
+ enc.SetIndent("", " ")
err = enc.Encode(&p)
if err != nil {
return err
}
- zwr.Close()
-
return fh.Close()
}
@@ -98,11 +101,135 @@ func GenId() string {
return base64.RawURLEncoding.EncodeToString(r)
}
+type User struct {
+ Username string `yaml:"Username"`
+ Password string `yaml:"Password"`
+ HashedPassword string `yaml:"HashedPassword"`
+}
+
+// CheckPass returns true if passwords match
+func (u *User) CheckPass(pass string) bool {
+ err := bcrypt.CompareHashAndPassword([]byte(u.HashedPassword), []byte(pass))
+ if err != nil {
+ return false
+ }
+ return true
+}
+
+type Conf struct {
+ // Populated after read used for lookups
+ Users map[string]*User `yaml:"Users"`
+}
+
+func hashPasswd(pass string) (string, error) {
+ b, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
+ return string(b), err
+}
+
+func readConf(fn string) (*Conf, error) {
+ fh, err := os.Open(fn)
+ if err != nil {
+ return nil, err
+ }
+
+ dec := yaml.NewDecoder(fh)
+ dec.KnownFields(true)
+
+ c := &Conf{}
+ err = dec.Decode(c)
+ if err != nil {
+ return nil, err
+ }
+ fh.Close()
+
+ changed := false
+ // Convert any plain passwords to HashedPasswords
+ for n, u := range c.Users {
+ u.Username = n
+ if u.Password != "" {
+ u.HashedPassword, err = hashPasswd(u.Password)
+ if err != nil {
+ return nil, err
+ }
+ u.Password = ""
+ changed = true
+ }
+ }
+
+ if changed {
+ fh, err := os.OpenFile(fn, os.O_TRUNC|os.O_RDWR, 0600)
+ if err != nil {
+ return nil, err
+ }
+
+ enc := yaml.NewEncoder(fh)
+ enc.SetIndent(2)
+ err = enc.Encode(c)
+ if err != nil {
+ return nil, err
+ }
+ fh.Close()
+ }
+
+ return c, nil
+}
+
+func loadOrGenKeys() (ed25519.PublicKey, ed25519.PrivateKey, error) {
+ var (
+ key ed25519.PrivateKey
+ pub ed25519.PublicKey
+ err error
+ )
+ if _, err = os.Stat("key"); err != nil {
+ pub, key, err = ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ fh, err := os.OpenFile("key", os.O_CREATE|os.O_RDWR, 0600)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ err = pem.Encode(fh, &pem.Block{
+ Type: "ED25519 PRIVATE KEY",
+ Bytes: key,
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+
+ fh.Close()
+ } else {
+ fh, err := os.Open("key")
+ if err != nil {
+ return nil, nil, err
+ }
+
+ b, err := io.ReadAll(fh)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ blk, _ := pem.Decode(b)
+ if blk == nil || blk.Type != "ED25519 PRIVATE KEY" {
+ return nil, nil, errors.New("Failed to decode PEM file on disk")
+ }
+
+ key = ed25519.PrivateKey(blk.Bytes)
+ pub = key.Public().(ed25519.PublicKey)
+ fh.Close()
+ }
+
+ return pub, key, err
+}
+
func main() {
- fl := flag.NewFlagSet("Brutally simple pastebin", flag.ExitOnError)
+ fl := flag.NewFlagSet("simple pastebin", flag.ExitOnError)
listen := fl.String("listen", ":6130", "Address to bind to, LISTEN_ADDR environment variable overrides")
debug := fl.Bool("d", false, "debugging add information to the logging output DEBUG=true|false controls this as well")
storage := fl.String("s", "", "Directory to serve, must be supplied via flag or STORAGE_DIR environment variable")
+ fl.IntVar(&ID_BYTES, "b", ID_BYTES, "How many random bytes for the id?")
_ = fl.Parse(os.Args[1:])
if addr := os.Getenv("LISTEN_ADDR"); addr != "" {
@@ -119,22 +246,42 @@ func main() {
logger.Fatal("Cannot continue without storage directory, set `-s` flag or STORAGE_DIR environment variable")
}
- err := os.Chdir(*storage)
+ err := os.MkdirAll(filepath.Join(*storage, "p"), 0755)
if err != nil {
logger.Fatal(err)
}
- mux := mux.NewRouter()
+ err = os.Chdir(*storage)
+ if err != nil {
+ logger.Fatal(err)
+ }
+
+ c, err := readConf("config.yml")
+ if err != nil {
+ logger.Fatal(err)
+ }
+ logger.Println("Config:")
+ b, _ := json.MarshalIndent(c, "", " ")
+ logger.Println(string(b))
- mux.HandleFunc("/new", newPaste)
- mux.HandleFunc("/view/{id}", loadPaste)
- mux.HandleFunc("/style.css", stylesheet)
- mux.HandleFunc("/", index)
+ pubKey, key, err := loadOrGenKeys()
+ if err != nil {
+ logger.Fatal(err)
+ }
+
+ r := mux.NewRouter()
+
+ r.Handle("/new", requireJWT(pubKey, c.Users, newPasteJson()))
+ r.HandleFunc("/view/{id}", loadPaste)
+ r.HandleFunc("/view/json/{id}", loadPasteJson)
+ r.PathPrefix("/static").Handler(http.FileServer(http.FS(staticFS)))
+ r.Handle("/login", handleLogin(key, c.Users))
+ r.HandleFunc("/", index)
logger.Println("listening on: ", *listen)
srv := &http.Server{
- Handler: mux,
+ Handler: r,
Addr: *listen,
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
@@ -142,6 +289,174 @@ func main() {
logger.Fatal(srv.ListenAndServe())
}
+func jsonResp(w http.ResponseWriter, code int, data interface{}) {
+ w.Header().Add("Content-type", "application/json")
+ w.WriteHeader(code)
+
+ enc := json.NewEncoder(w)
+ err := enc.Encode(data)
+ if err != nil {
+ logger.Println("While jsonResp: ", err)
+ }
+}
+
+func jsonErr(logMsg string, msg string,
+ w http.ResponseWriter, statusCode int) {
+
+ logger.Println(logMsg)
+
+ w.Header().Add("Content-type", "application/json")
+ w.WriteHeader(statusCode)
+
+ enc := json.NewEncoder(w)
+ err := enc.Encode(map[string]string{"error": msg})
+ if err != nil {
+ logger.Println("While logMsg: ", err)
+ }
+}
+
+func handleLogin(key ed25519.PrivateKey, users map[string]*User) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ reqU := &User{}
+ dec := json.NewDecoder(r.Body)
+
+ err := dec.Decode(reqU)
+ if err != nil {
+ jsonErr(
+ fmt.Sprintf("Encountered error decoding user: %s", err),
+ "invalid json",
+ w, http.StatusBadRequest)
+ return
+ }
+
+ u, ok := users[reqU.Username]
+
+ if !ok || reqU.Username == "" || reqU.Password == "" {
+ jsonErr(
+ "Invalid username or password",
+ "invalid username or password",
+ w, http.StatusBadRequest)
+ return
+ }
+
+ if !u.CheckPass(reqU.Password) {
+ jsonErr(
+ fmt.Sprintf("Bad password for: %s", u.Username),
+ "bad username or password",
+ w, http.StatusBadRequest)
+ return
+ }
+
+ t := jwt.NewWithClaims(jwt.SigningMethodEdDSA, &jwt.StandardClaims{
+ ExpiresAt: time.Now().Unix() + (12 * 60 * 60),
+ Subject: u.Username,
+ })
+
+ s, err := t.SignedString(key)
+ if err != nil {
+ jsonErr(
+ fmt.Sprintf("Failed to sign: %s", err),
+ "internal server error",
+ w, http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Add("Content-type", "application/json")
+ w.Header().Add("Authorization", "Bearer "+s)
+ enc := json.NewEncoder(w)
+ enc.Encode(map[string]string{
+ "token": s,
+ })
+ })
+}
+
+func requireJWT(key ed25519.PublicKey, users map[string]*User,
+ next http.Handler) http.Handler {
+
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
+ tokenS := r.Header.Get("Authorization")
+ if tokenS == "" {
+ jsonErr("Empty token received", "Unauthorized: empty token",
+ w, http.StatusUnauthorized)
+ return
+ }
+
+ tokenS = strings.TrimPrefix(tokenS, "Bearer ")
+
+ claims := &jwt.StandardClaims{}
+
+ token, err := jwt.ParseWithClaims(tokenS, claims,
+ func(token *jwt.Token) (interface{}, error) {
+ return key, nil
+ })
+
+ if err != nil {
+ jsonErr(fmt.Sprintf("Error parsing token: %s", err),
+ "Unauthorized: invalid token", w, http.StatusUnauthorized)
+ return
+ }
+
+ if !token.Valid {
+ jsonErr(
+ fmt.Sprintf("Token for %s expires at: %v", claims.Subject,
+ claims.ExpiresAt),
+ "Unauthorized: invalid token", w, http.StatusUnauthorized)
+ return
+ }
+
+ u, ok := users[claims.Subject]
+
+ if !ok {
+ jsonErr(
+ fmt.Sprintf("User %s not valid", claims.Subject),
+ "invalid user", w, http.StatusUnauthorized)
+ return
+ }
+
+ logger.Printf("%s -> authed user: %s", r.URL.Path, u.Username)
+
+ next.ServeHTTP(w, r)
+ })
+}
+
+func newPasteJson() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ dec := json.NewDecoder(r.Body)
+ dec.DisallowUnknownFields()
+
+ paste := &Paste{}
+
+ err := dec.Decode(paste)
+ if err != nil {
+ jsonErr(fmt.Sprintf("Encountered error decoding: %s", err),
+ "unable to decode input", w, http.StatusBadRequest)
+ return
+ }
+
+ if paste.Content == "" {
+ jsonErr("Not saving paste with empty content",
+ "empty content", w, http.StatusBadRequest)
+ return
+ }
+
+ paste.Id = GenId()
+
+ err = paste.Save()
+
+ if err != nil {
+ jsonErr(fmt.Sprintf("Encountered error saving paste: %s", err),
+ "internal server error", w, http.StatusInternalServerError)
+ return
+ }
+
+ jsonResp(w, http.StatusOK, map[string]string{
+ "status": "ok",
+ "id": paste.Id,
+ })
+ })
+}
+
func newPaste(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
@@ -162,7 +477,7 @@ func newPaste(w http.ResponseWriter, r *http.Request) {
p := &Paste{
Id: GenId(),
Title: title,
- Content: []byte(content),
+ Content: content,
}
err = p.Save()
@@ -175,13 +490,45 @@ func newPaste(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/view/"+p.Id, http.StatusFound)
}
+func loadPasteJson(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+
+ id, ok := vars["id"]
+ if !ok {
+ jsonErr("No ID supplied",
+ "ID must be supplied", w, http.StatusBadRequest)
+ return
+ }
+
+ p := &Paste{Id: id}
+
+ err := p.Load()
+ if err != nil {
+ logger.Println(err)
+ if errors.Is(err, os.ErrNotExist) {
+ jsonErr(
+ fmt.Sprintf("Snip with id: %s not found", id),
+ "ID not found", w, http.StatusNotFound)
+ return
+ } else {
+ jsonErr(
+ fmt.Sprintf("Snip with id faild loading: %s", err),
+ "Failed to load snippet", w, http.StatusInternalServerError)
+ return
+ }
+ return
+ }
+
+ jsonResp(w, http.StatusOK, p)
+}
+
func loadPaste(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, ok := vars["id"]
if !ok {
w.WriteHeader(http.StatusBadRequest)
- err := errorTemplate.Execute(w, map[string]string{
+ err := tpls["error"].Execute(w, map[string]string{
"Short": "ID Not found",
"Long": "There was no ID supplied",
})
@@ -198,13 +545,13 @@ func loadPaste(w http.ResponseWriter, r *http.Request) {
logger.Println(err)
if errors.Is(err, os.ErrNotExist) {
w.WriteHeader(http.StatusNotFound)
- err = errorTemplate.Execute(w, map[string]string{
+ err = tpls["error"].Execute(w, map[string]string{
"Short": "ID Not found",
"Long": "ID: " + p.Id + "Was not found",
})
} else {
w.WriteHeader(http.StatusInternalServerError)
- err = errorTemplate.Execute(w, map[string]string{
+ err = tpls["error"].Execute(w, map[string]string{
"Short": "ID Not found",
"Long": "There was an issue reading ID: " + p.Id,
})
@@ -215,19 +562,14 @@ func loadPaste(w http.ResponseWriter, r *http.Request) {
return
}
- err = viewTemplate.Execute(w, p)
+ err = tpls["view"].Execute(w, p)
if err != nil {
logger.Println(err)
}
}
-func stylesheet(w http.ResponseWriter, r *http.Request) {
- w.Header().Add("Content-type", "text/css")
- _, _ = w.Write(stylesheetContent)
-}
-
func index(w http.ResponseWriter, r *http.Request) {
- err := indexTemplate.Execute(w, nil)
+ err := tpls["index"].Execute(w, nil)
if err != nil {
logger.Println(err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
diff --git a/style.css b/static/style.css
index 472b18c..472b18c 100644
--- a/style.css
+++ b/static/style.css
diff --git a/error.tpl b/templates/base.tpl
index c53fa4f..ec1aeb5 100644
--- a/error.tpl
+++ b/templates/base.tpl
@@ -1,22 +1,21 @@
+{{define "base"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
- <link rel="stylesheet" href="/style.css" defer>
- <title>Error {{.Short}}</title>
+ <link rel="stylesheet" href="/static/style.css" defer>
+ <title>{{template "title" .}}</title>
</head>
-<body>
+
<nav>
<a href="/">Home</a>
<div style="display: block; float: right;">
</div>
</nav>
+<body>
-<h1>{{.Short}}</h1>
-
-{{.Long}}
+{{template "content" .}}
</body>
</html>
-
-
+{{end}}
diff --git a/templates/error.tpl b/templates/error.tpl
new file mode 100644
index 0000000..4b36bdb
--- /dev/null
+++ b/templates/error.tpl
@@ -0,0 +1,11 @@
+{{template "base" .}}
+
+{{define "title"}}Error {{.Short}}{{end}}
+
+{{define "content"}}
+
+<h1>{{.Short}}</h1>
+
+{{.Long}}
+
+{{end}}
diff --git a/templates/index.tpl b/templates/index.tpl
new file mode 100644
index 0000000..10ede35
--- /dev/null
+++ b/templates/index.tpl
@@ -0,0 +1,60 @@
+{{template "base" .}}
+
+{{define "title"}}Simple Pastebin{{end}}
+
+{{define "content"}}
+<h1>Simple pastebin</h1>
+
+This is a simple pastebin that effectively only has API access.
+
+Usernames are hard coded into the configuration file. The format is simple:
+
+<pre><code>Users:
+ username:
+ Password: "password"
+</code></pre>
+
+If there are any non empty `Password` fields they will be replaced with the
+hash on first run.
+
+
+Run a request to `/login` to receieve a token:
+
+<pre><code>$ curl -D /dev/fd/2 -d '{"Username": "mitch", "Password": "secret"}' localhost:6130/login
+HTTP/1.1 200 OK
+Authorization: Bearer eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Mjk2MjY4ODMsInN1YiI6Im1pdGNoIn0.8t5IU8WCVWwMozWyufGqFFmKF1mggLI7V8U1tX-u3yllTalbQRgG2PrxPQeVU9pAM6fuapmydXXZYGpehezqDw
+Content-Type: application/json
+Date: Sat, 21 Aug 2021 22:08:03 GMT
+Content-Length: 180
+
+{"token":"eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Mjk2MjY4ODMsInN1YiI6Im1pdGNoIn0.8t5IU8WCVWwMozWyufGqFFmKF1mggLI7V8U1tX-u3yllTalbQRgG2PrxPQeVU9pAM6fuapmydXXZYGpehezqDw"}
+$
+</code></pre>
+
+To add a paste use `/new`:
+
+<pre><code>$ export token=eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2Mjk2MjY4ODMsInN1YiI6Im1pdGNoIn0.8t5IU8WCVWwMozWyufGqFFmKF1mggLI7V8U1tX-u3yllTalbQRgG2PrxPQeVU9pAM6fuapmydXXZYGpehezqDw
+$ curl -D /dev/fd/2 -X POST -d '
+ {
+ "Title": "My title!",
+ "Tags": {"code": null, "c": null},
+ "Content": "This is my test content.\n\n\n"
+ }' -H "Authorization: Bearer $token" localhost:6130/new
+HTTP/1.1 200 OK
+Content-Type: application/json
+Date: Sat, 21 Aug 2021 22:20:53 GMT
+Content-Length: 46
+
+{"id":"YAv-lFYh9xpnP6ZZ0-Hu_w","status":"ok"}
+$
+</code></pre>
+
+Viewing the json is pretty easy:
+
+<pre><code>$ curl localhost:6130/view/json/YAv-lFYh9xpnP6ZZ0-Hu_w
+{"Id":"YAv-lFYh9xpnP6ZZ0-Hu_w","Title":"My
+title!","Tags":{"c":{},"code":{}},"Content":"This is my test content.\n\n\n"}
+$
+</code></pre>
+
+{{end}}
diff --git a/templates/view.tpl b/templates/view.tpl
new file mode 100644
index 0000000..76efe57
--- /dev/null
+++ b/templates/view.tpl
@@ -0,0 +1,9 @@
+{{template "base" .}}
+
+{{define "title"}}Paste: {{.Title}}{{end}}
+
+{{define "content"}}
+<h1>Paste: "{{.Title}}"</h1>
+
+<pre><code>{{.GetContent}}</pre></code>
+{{end}}
diff --git a/view.tpl b/view.tpl
deleted file mode 100644
index 84c5ebf..0000000
--- a/view.tpl
+++ /dev/null
@@ -1,21 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <link rel="stylesheet" href="/style.css" defer>
- <title>Paste: {{.Title}}</title>
-</head>
-<body>
-<nav>
- <a href="/">Home</a>
- <div style="display: block; float: right;">
- </div>
-</nav>
-
-<h1>Paste: "{{.Title}}"</h1>
-
-<pre><code>{{.GetContent}}</pre></code>
-
-</body>
-</html>
-