aboutsummaryrefslogtreecommitdiff
path: root/cmd/server
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/server')
-rw-r--r--cmd/server/app.go18
-rw-r--r--cmd/server/auth.go108
-rw-r--r--cmd/server/dashboard.go5
-rw-r--r--cmd/server/edit.go90
-rw-r--r--cmd/server/feed.go2
-rw-r--r--cmd/server/handlers.go7
-rw-r--r--cmd/server/main.go7
-rw-r--r--cmd/server/middleware.go142
8 files changed, 373 insertions, 6 deletions
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)
+ })
+}