diff options
| author | Mitchell Riedstra <mitch@riedstra.dev> | 2022-06-19 23:57:04 -0400 |
|---|---|---|
| committer | Mitchell Riedstra <mitch@riedstra.dev> | 2022-06-19 23:57:04 -0400 |
| commit | bf7d9c79cae53f64fcd04527248987bd4e7ca3c4 (patch) | |
| tree | 0c763f6545cee4a287f5e6fa0a45489a85454325 | |
| parent | 235b8f871fdfa35f9595268d194d28a3de655ec0 (diff) | |
| download | go-website-0.0.17a.tar.gz go-website-0.0.17a.tar.xz | |
0.0.17a / Alpha. Introduce users and page editing.v0.0.17a
Breaking changes:
inc/base.html is now tpl/base.md by default. This can be overridden
on the command line.
404.md is now tpl/404.md. This can be overridden with templatedirectory
in the configuration file.
Additional files:
`auth.json` file that stores credentials and settings for
authorization cookie.
Further notes:
This will likely receive some major updates and changes over
the next few commits. The scaffolidng is now in place for
user accounts, login handling, and page editing.
It's all extremely basic at the moment, on the idea list:
Listing of all markdown files
File uploader and general content management
Flags to turn on/off git integration for edits.
Download / Upload of all markdown files as a backup/restore.
It's of course, all subject to change.
| -rw-r--r-- | .golangci.yml | 2 | ||||
| -rw-r--r-- | cmd/server/app.go | 18 | ||||
| -rw-r--r-- | cmd/server/auth.go | 108 | ||||
| -rw-r--r-- | cmd/server/dashboard.go | 5 | ||||
| -rw-r--r-- | cmd/server/edit.go | 90 | ||||
| -rw-r--r-- | cmd/server/feed.go | 2 | ||||
| -rw-r--r-- | cmd/server/handlers.go | 7 | ||||
| -rw-r--r-- | cmd/server/main.go | 7 | ||||
| -rw-r--r-- | cmd/server/middleware.go | 142 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | go.sum | 10 | ||||
| -rw-r--r-- | page/page.go | 58 | ||||
| -rw-r--r-- | page/render.go | 137 | ||||
| -rw-r--r-- | users/main.go | 43 |
14 files changed, 589 insertions, 42 deletions
diff --git a/.golangci.yml b/.golangci.yml index 319e4af..2201c93 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -27,7 +27,7 @@ linters: - godot - godox - gofmt - - gofumpt + # - gofumpt - goheader # - goimports - golint diff --git a/cmd/server/app.go b/cmd/server/app.go index 4f62a09..d4977ab 100644 --- a/cmd/server/app.go +++ b/cmd/server/app.go @@ -16,11 +16,15 @@ type App struct { redisPool *redis.Pool RedisKey string - ReIndexPath string - StaticDirectory string - BaseTemplate string - DocumentSplit string - Suffix string + ReIndexPath string + StaticDirectory string + BaseTemplate string + TemplateDirectory string + DocumentSplit string + Suffix string + + // Related to user authentication + auth *Auth // Related to the Atom feed Title string @@ -59,6 +63,10 @@ func loadConf(fn string) (*App, error) { page.BaseTemplate = app.BaseTemplate } + if app.TemplateDirectory != "" { + page.TemplateDirectory = app.TemplateDirectory + } + if app.DocumentSplit != "" { page.DocumentSplit = app.DocumentSplit } diff --git a/cmd/server/auth.go b/cmd/server/auth.go new file mode 100644 index 0000000..caade97 --- /dev/null +++ b/cmd/server/auth.go @@ -0,0 +1,108 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + + "riedstra.dev/mitch/go-website/users" +) + +type Auth struct { + Users []*users.SiteUser `json:"Users"` + // How long are JWTs valid? + LoginHours int `json:"LoginHours"` + // JWT secret + TokenKey string `json:"TokenKey"` + // Are cookies HTTP only? + HTTPOnly bool `json:"HTTPOnly"` + // Do they require HTTPs? + Secure bool `json:"SecureCookie"` + // See https://pkg.go.dev/net/http#SameSite + // You probably want this set to 3 + SameSiteStrict http.SameSite `json:"SameSiteStrict"` +} + +func GenTokenKey() string { + r := make([]byte, 16) // 128 bits + _, err := rand.Read(r) + + if err != nil { + // Not my favorite thing, but I consider this to be a + // critical issue + panic(fmt.Errorf("reading random bytes: %w", err)) + } + + return base64.RawURLEncoding.EncodeToString(r) +} + +func (a *App) ReadAuth(fn string) error { //nolint + auth := &Auth{ + Users: []*users.SiteUser{ + { + Username: "admin", + Password: "admin", + }, + }, + LoginHours: 1, + TokenKey: GenTokenKey(), + HTTPOnly: true, + Secure: true, + SameSiteStrict: http.SameSiteStrictMode, + } + + var dec *json.Decoder + + fh, err := os.Open(fn) + if err != nil { + if strings.Contains(err.Error(), "no such file") { + goto write + } + + return fmt.Errorf("opening %s: %w", fn, err) + } + + dec = json.NewDecoder(fh) + dec.DisallowUnknownFields() + + err = dec.Decode(auth) + if err != nil { + return fmt.Errorf("decoding file %s: %w", fn, err) + } + + err = fh.Close() + if err != nil { + return fmt.Errorf("closing file %s: %w", fn, err) + } + + for _, u := range auth.Users { + err = u.SetPasswordHashIfNecessary() + if err != nil { + return fmt.Errorf("setting password hash for: %s: %w", + u.Username, err) + } + } + +write: + fh, err = os.OpenFile(fn, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + + if err != nil { + return fmt.Errorf("opening file %s: %w", fn, err) + } + + enc := json.NewEncoder(fh) + enc.SetIndent("", " ") + + err = enc.Encode(auth) + if err != nil { + return fmt.Errorf("encoding to file %s: %w", fn, err) + } + + a.auth = auth + + return nil +} diff --git a/cmd/server/dashboard.go b/cmd/server/dashboard.go new file mode 100644 index 0000000..a07e722 --- /dev/null +++ b/cmd/server/dashboard.go @@ -0,0 +1,5 @@ +package main + +// func (a *App) DashboardHandler(w http.ResponseWriter, r *http.Request) { +// page.Render(" +// } diff --git a/cmd/server/edit.go b/cmd/server/edit.go new file mode 100644 index 0000000..730e6e9 --- /dev/null +++ b/cmd/server/edit.go @@ -0,0 +1,90 @@ +package main + +import ( + "bytes" + "html" + "io" + "log" + "net/http" + "os" + "path/filepath" + + "riedstra.dev/mitch/go-website/page" +) + +func (a *App) EditPage(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + page.Render(w, r, page.TemplateDirectory+"/4xx", map[string]interface{}{ + "Title": "Method Not allowed", + "Description": "Method not allowed", + }, http.StatusMethodNotAllowed) + + return + } + + p := r.URL.Path + + p = filepath.Clean(p) + + fh, err := os.Open("./" + p + page.Suffix) + if err != nil { + log.Printf("opening page: %s", err) + a.Err500Default(w, r) + + return + } + + b, err := io.ReadAll(fh) + if err != nil { + log.Printf("opening page: %s", err) + a.Err500Default(w, r) + + return + } + + page.Render(w, r, page.TemplateDirectory+"/edit", map[string]interface{}{ + "Page": p, + "Content": html.EscapeString(string(b)), + }, http.StatusOK) +} + +func (a *App) SaveEditPage(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + a.Err500Default(w, r) + + return + } + + p := r.URL.Path + content := r.FormValue("content") + + content = html.UnescapeString(content) + p = filepath.Clean(p) + + fn := "./" + p + page.Suffix + + fh, err := os.OpenFile(fn, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + log.Printf("opening file %s for writing: %s", fn, err) + a.Err500Default(w, r) + + return + } + + c := bytes.ReplaceAll([]byte(content), []byte{'\r'}, []byte{}) + + _, err = fh.Write(c) + if err != nil { + log.Printf("opening file %s for writing: %s", fn, err) + a.Err500Default(w, r) + + return + } + + http.Redirect(w, r, "/"+r.URL.Path, http.StatusFound) + + err = a.ClearRedis() // Clear out the cache if any + if err != nil { + log.Printf("after editing %s: %s", fn, err) + } +} diff --git a/cmd/server/feed.go b/cmd/server/feed.go index 880eeb4..212e5da 100644 --- a/cmd/server/feed.go +++ b/cmd/server/feed.go @@ -203,7 +203,7 @@ func (a *App) FeedHandler(w http.ResponseWriter, r *http.Request) { //nolint:fun } entry := Entry{ - Title: p.Title, + Title: p.Title(), Updated: &p.Date.Time, Links: []Link{{Href: strings.Join([]string{a.SiteURL, p.Path()}, "/")}}, } diff --git a/cmd/server/handlers.go b/cmd/server/handlers.go index 205d462..1711ae3 100644 --- a/cmd/server/handlers.go +++ b/cmd/server/handlers.go @@ -15,6 +15,13 @@ func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { rtr.HandleFunc(a.ReIndexPath, a.RebuildIndexHandler) rtr.PathPrefix("/static/").Handler(a.StaticHandler()) + rtr.HandleFunc("/login", a.LoginHandler) + rtr.Handle("/logout", a.RequiresLogin(http.HandlerFunc(a.LogoutHandler))) + rtr.PathPrefix("/edit/").Handler( + a.RequiresLogin(http.StripPrefix("/edit/", http.HandlerFunc(a.EditPage)))).Methods("GET") + rtr.PathPrefix("/edit/").Handler( + a.RequiresLogin(http.StripPrefix("/edit/", http.HandlerFunc(a.SaveEditPage)))).Methods("POST") + if a.redisPool != nil { rtr.PathPrefix(fmt.Sprintf("/%s/{tag}", a.FeedPrefix)).Handler( rediscache.HandleWithParams(a.redisPool, a.RedisKey, diff --git a/cmd/server/main.go b/cmd/server/main.go index 912cba0..ee8cf6f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -28,6 +28,8 @@ func main() { //nolint:funlen directory := fl.String("d", ".", "Directory to serve.") version := fl.Bool("v", false, "Print the version then exit") confFn := fl.String("c", "conf.yml", "Location for the config file") + authConfFn := fl.String("ac", "auth.json", + "Location for authorization configuration file") verbose := fl.Bool("V", false, "Be more verbose ( dump config, etc ) ") fl.StringVar(&page.TimeFormat, "T", page.TimeFormat, "Set the page time format, be careful with this") @@ -61,6 +63,11 @@ func main() { //nolint:funlen app = &App{} } + err = app.ReadAuth(*authConfFn) + if err != nil { + log.Println(err) + } + if app.ReIndexPath == "" || *indexPath != defaultIndexPath { app.ReIndexPath = *indexPath } diff --git a/cmd/server/middleware.go b/cmd/server/middleware.go new file mode 100644 index 0000000..5e4bf26 --- /dev/null +++ b/cmd/server/middleware.go @@ -0,0 +1,142 @@ +package main + +import ( + "log" + "net/http" + "time" + + jwt "github.com/dgrijalva/jwt-go" + "riedstra.dev/mitch/go-website/page" + "riedstra.dev/mitch/go-website/users" +) + +func (a *App) Err5xx(w http.ResponseWriter, r *http.Request, + statusCode int, title, desc string) { + page.Render5xx(w, r, map[string]interface{}{ + "Error": title, + "Description": desc, + }, statusCode) +} + +func (a *App) Err500Default(w http.ResponseWriter, r *http.Request) { + a.Err5xx(w, r, http.StatusInternalServerError, "Internal server error", + "Internal server error.") +} + +func (a *App) LogoutHandler(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: "Auth", + HttpOnly: a.auth.HTTPOnly, + SameSite: a.auth.SameSiteStrict, + Secure: a.auth.Secure, + Value: "logout", + Expires: time.Now().Add(time.Second * 15), //nolint + }) + + http.Redirect(w, r, "/", http.StatusFound) +} + +func (a *App) LoginHandler(w http.ResponseWriter, r *http.Request) { //nolint + if r.Method == "GET" { + page.RenderForPath(w, r, "login") + + return + } + + if r.Method != "POST" { + a.Err500Default(w, r) + + return + } + + log.Printf("made it") + + username := r.FormValue("username") + password := r.FormValue("password") + + var ( + err error = nil + u *users.SiteUser + found = false + ) + + for _, u = range a.auth.Users { + if u.Username == username { + err = u.CheckPassword(password) + found = true + } + } + + if err != nil || !found { + page.Render(w, r, "login", map[string]interface{}{ + "Error": "Invalid username or password", + "Username": username, + }, http.StatusUnauthorized) + + return + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS512, &jwt.StandardClaims{ + ExpiresAt: time.Now().Add( + time.Hour * time.Duration(a.auth.LoginHours)).Unix(), + Id: u.Username, + }) + + ss, err := token.SignedString([]byte(a.auth.TokenKey)) + if err != nil { + log.Println("login: encountered while setting up JWT: ", err) + a.Err500Default(w, r) + + return + } + + log.Println(ss) + + http.SetCookie(w, &http.Cookie{ + Name: "Auth", + HttpOnly: a.auth.HTTPOnly, + SameSite: a.auth.SameSiteStrict, + Secure: a.auth.Secure, + Value: ss, + }) + + http.Redirect(w, r, "/login", http.StatusFound) +} + +func (a *App) RequiresLogin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mustLoginResp := func() { + page.Render(w, r, "login", map[string]interface{}{ + "Error": "You must login to view this page", + }, http.StatusUnauthorized) + } + + c, err := r.Cookie("Auth") + if err != nil { + mustLoginResp() + + return + } + + token, err := jwt.Parse(c.Value, + func(token *jwt.Token) (interface{}, error) { + return []byte(a.auth.TokenKey), nil + }, + ) + + if err != nil { + log.Printf("Unauthorized request %s %s", r.Method, r.URL.Path) + mustLoginResp() + + return + } + + if !token.Valid { + mustLoginResp() + + return + } + + next.ServeHTTP(w, r) + }) +} @@ -3,12 +3,14 @@ module riedstra.dev/mitch/go-website go 1.13 require ( + github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/gomodule/redigo v1.8.5 // indirect github.com/gorilla/mux v1.8.0 github.com/kr/pretty v0.1.0 // indirect github.com/russross/blackfriday v2.0.0+incompatible github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect + golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect google.golang.org/appengine v1.6.7 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 @@ -1,5 +1,7 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/gomodule/redigo v1.8.5 h1:nRAxCa+SVsyjSBrtZmG/cqb6VbTmuRzpg/PoTFlpumc= @@ -25,11 +27,19 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM= +golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= diff --git a/page/page.go b/page/page.go index b2cc3e1..adb2362 100644 --- a/page/page.go +++ b/page/page.go @@ -46,11 +46,11 @@ import ( // The exported fields can be filled in the yaml at the top of a page and // utilized within. type Page struct { - path string - Title string - Description string - AuthorName string - AuthorEmail string + path string + DefaultTitle string `yaml:"title"` + DefaultDescription string `yaml:"description"` + AuthorName string + AuthorEmail string // Tags to apply to the page in question. Useful for Index() Tags map[string]interface{} Date *PageTime @@ -76,8 +76,14 @@ var CacheIndex = true // backend. var FileSystem = os.DirFS(".") +// TemplateDirectory is the parent directory which templates are stored, +// usually the BaseTemplate is stored here as well, though it does not +// have to be. Currently this is used for the 4xx and 5xx pages. ( 4xx.md +// and 5xx.md respectively. +var TemplateDirectory = "tpl" + // BaseTemplate can be adjusted to change the base template used in rendering. -var BaseTemplate = "inc/base.html" +var BaseTemplate = "tpl/base.md" // Suffix is applied to all pages for reading off of the disk. var Suffix = ".md" @@ -107,6 +113,46 @@ func (p Page) Path() string { return filepath.ToSlash(p.path) } +// Title returns `DefaultTitle` unless `.Vars.Title` is +// set, then it returns that instead. +func (p Page) Title() string { + if p.Vars == nil { + return p.DefaultTitle + } + + t, ok := p.Vars["Title"] + if !ok { + return p.DefaultTitle + } + + nt, ok := t.(string) + if !ok { + return p.DefaultTitle + } + + return nt +} + +// Description returns `DefaultDescription` unless `.Vars.Description` is +// set, then it returns that instead. +func (p Page) Description() string { + if p.Vars == nil { + return p.DefaultDescription + } + + t, ok := p.Vars["Description"] + if !ok { + return p.DefaultDescription + } + + nt, ok := t.(string) + if !ok { + return p.DefaultDescription + } + + return nt +} + // Global is specifically for use inside of a page markdown file or // in a base template. This simply returns the package Global variable. func (p *Page) Global() interface{} { diff --git a/page/render.go b/page/render.go index 6cbabf9..d35ba38 100644 --- a/page/render.go +++ b/page/render.go @@ -1,20 +1,104 @@ package page import ( + "bytes" "net/http" "path/filepath" "strings" ) -// Render is a lower level option, allowing you to specify local -// variables and the status code in which to return. -func Render(w http.ResponseWriter, r *http.Request, - path string, vars map[string]interface{}, statusCode int) { +func getURLPath(r *http.Request) string { u := r.URL.Path if u == "/" { u = "/index" } + return u +} + +// Render5xx is automatically called if any render fails, +// additionally you can call it for your own uses. It will +// try to use the 5xx template but will fall back to a plain +// page if that fails. +func Render5xx(w http.ResponseWriter, r *http.Request, + vars map[string]interface{}, statusCode int) { + u := getURLPath(r) + + Logger.Printf("%s %s %d %s", + r.RemoteAddr, + r.Method, + statusCode, + u) + + p := NewPage(TemplateDirectory + "/5xx") + + buf := &bytes.Buffer{} + + err := p.Render(buf) + if err != nil { + Logger.Printf("%s %s path: %s while trying 5xx: %s", + r.RemoteAddr, + r.Method, + u, + err) + http.Error(w, "Internal server error", statusCode) + + return + } + + w.WriteHeader(statusCode) + + _, _ = w.Write(buf.Bytes()) + + Logger.Printf("%s %s %d %s", r.RemoteAddr, r.Method, statusCode, u) +} + +// Render4xx is mostly used to render a 404 page, pulls from 404 template. +// automatically called by Render if a page is missing. +func Render4xx(w http.ResponseWriter, r *http.Request, + vars map[string]interface{}, statusCode int) { + u := getURLPath(r) + + Logger.Printf("%s %s %d %s", + r.RemoteAddr, + r.Method, + statusCode, + u) + + p := NewPage(TemplateDirectory + "/4xx") + + buf := &bytes.Buffer{} + + err := p.Render(buf) + if err != nil { + Logger.Printf("%s %s path: %s while trying 404: %s", + r.RemoteAddr, + r.Method, + u, + err) + Render5xx(w, r, vars, http.StatusInternalServerError) + + return + } + + w.WriteHeader(statusCode) + + _, err = w.Write(buf.Bytes()) + if err != nil { + Render5xx(w, r, nil, http.StatusInternalServerError) + + return + } + + Logger.Printf("%s %s %d %s", r.RemoteAddr, r.Method, statusCode, u) +} + +// Render is a lower level option, allowing you to specify local +// variables and the status code in which to return. +func Render(w http.ResponseWriter, r *http.Request, + path string, vars map[string]interface{}, statusCode int) { + u := getURLPath(r) + u = filepath.Join(".", u) // Sepcifically use the specified path for the page @@ -24,42 +108,37 @@ func Render(w http.ResponseWriter, r *http.Request, p.Vars = vars } - err := p.Render(w) + buf := &bytes.Buffer{} + + err := p.Render(buf) if err != nil { if strings.HasSuffix(err.Error(), "no such file or directory") { - Logger.Printf("%s %s %d %s", - r.RemoteAddr, - r.Method, - http.StatusNotFound, - u) + Render4xx(w, r, vars, http.StatusNotFound) - p = NewPage("404") - - w.WriteHeader(http.StatusNotFound) + return + } - err := p.Render(w) - if err != nil { - Logger.Printf("%s %s path: %s while trying 404: %s", - r.RemoteAddr, - r.Method, - u, - err) - http.Error(w, "Internal server error", - http.StatusInternalServerError) + Logger.Printf("%s %s path: %s rendering encountered: %s", + r.RemoteAddr, + r.Method, + u, + err) + Render5xx(w, r, vars, http.StatusInternalServerError) - return - } + return + } - return - } + w.WriteHeader(statusCode) - Logger.Printf("%s %s path: %s encountered: %s", + _, err = w.Write(buf.Bytes()) + if err != nil { + Logger.Printf("%s %s %d %s: while writing buf: %s", r.RemoteAddr, r.Method, + statusCode, u, err) - http.Error(w, "Internal server error", - http.StatusInternalServerError) + Render5xx(w, r, nil, http.StatusInternalServerError) return } diff --git a/users/main.go b/users/main.go new file mode 100644 index 0000000..edb306f --- /dev/null +++ b/users/main.go @@ -0,0 +1,43 @@ +package users + +import ( + "fmt" + + "golang.org/x/crypto/bcrypt" +) + +type SiteUser struct { + Username string `yaml:"username"` + PasswordHash string `yaml:"password-hash"` + Password string `yaml:"password"` +} + +func (su *SiteUser) SetPasswordHashIfNecessary() error { + if su.Password == "" { + return nil + } + + res, err := bcrypt.GenerateFromPassword([]byte(su.Password), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("SetPasswordHashIfNecessary: %w", err) + } + + su.Password = "" + su.PasswordHash = string(res) + + return nil +} + +func (su *SiteUser) CheckPassword(pass string) error { + err := su.SetPasswordHashIfNecessary() + if err != nil { + return err + } + + err = bcrypt.CompareHashAndPassword([]byte(su.PasswordHash), []byte(pass)) + if err != nil { + return fmt.Errorf("CheckPassword: %w", err) + } + + return nil +} |
