Skip to content

Commit

Permalink
Issue umputun#31: Replace separate UI with the embedded HTMX based
Browse files Browse the repository at this point in the history
  • Loading branch information
oneils committed Aug 20, 2023
1 parent ea64f3f commit 8523524
Show file tree
Hide file tree
Showing 19 changed files with 855 additions and 7 deletions.
43 changes: 42 additions & 1 deletion backend/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ package main
import (
"context"
"fmt"
"github.com/umputun/secrets/backend/ui"
"html/template"
"io/fs"
"os"
"path/filepath"
"time"

log "github.com/go-pkgz/lgr"
Expand All @@ -21,8 +25,9 @@ var opts struct {
MaxExpire time.Duration `long:"expire" env:"MAX_EXPIRE" default:"24h" description:"max lifetime"`
MaxPinAttempts int `long:"pinattempts" env:"PIN_ATTEMPTS" default:"3" description:"max attempts to enter pin"`
BoltDB string `long:"bolt" env:"BOLT_FILE" default:"/tmp/secrets.bd" description:"boltdb file"`
WebRoot string `long:"web" env:"WEB" default:"/srv/docroot" description:"web ui location"`
WebRoot string `long:"web" env:"WEB" default:"./ui/static/" description:"web ui location"`
Dbg bool `long:"dbg" description:"debug mode"`
Domain string `short:"d" long:"domain" env:"DOMAIN" description:"site domain" required:"true"`
}

var revision string
Expand All @@ -36,6 +41,12 @@ func main() {

setupLog(opts.Dbg)

templateCache, err := newTemplateCache()
if err != nil {
log.Printf("[ERROR] can't create template cache, %+v", err)
os.Exit(1)
}

dataStore := getEngine(opts.Engine, opts.BoltDB)
crypter := messager.Crypt{Key: messager.MakeSignKey(opts.SignKey, opts.PinSize)}
params := messager.Params{MaxDuration: opts.MaxExpire, MaxPinAttempts: opts.MaxPinAttempts}
Expand All @@ -46,6 +57,8 @@ func main() {
MaxPinAttempts: opts.MaxPinAttempts,
WebRoot: opts.WebRoot,
Version: revision,
Domain: opts.Domain,
TemplateCache: templateCache,
}
if err := srv.Run(context.Background()); err != nil {
log.Printf("[ERROR] failed, %+v", err)
Expand Down Expand Up @@ -74,3 +87,31 @@ func setupLog(dbg bool) {
}
log.Setup(log.Msec, log.LevelBraces)
}

func newTemplateCache() (map[string]*template.Template, error) {
cache := map[string]*template.Template{}

pages, err := fs.Glob(ui.Files, "html/*/*.tmpl.html")

if err != nil {
return nil, err
}

for _, page := range pages {
name := filepath.Base(page)

patterns := []string{
"html/index.tmpl.html",
"html/partials/*.tmpl.html",
page,
}

ts, err := template.New(name).ParseFS(ui.Files, patterns...)
if err != nil {
return nil, err
}
cache[name] = ts
}

return cache, nil
}
37 changes: 31 additions & 6 deletions backend/app/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server

import (
"context"
"html/template"
"net/http"
"strings"
"time"
Expand All @@ -29,6 +30,8 @@ type Server struct {
MaxExpire time.Duration
WebRoot string
Version string
Domain string
TemplateCache map[string]*template.Template
}

// Messager interface making and loading messages
Expand Down Expand Up @@ -86,7 +89,30 @@ func (s Server) routes() chi.Router {
render.PlainText(w, r, "User-agent: *\nDisallow: /api/\nDisallow: /show/\n")
})

router.NotFound(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/v1") {
render.Status(r, http.StatusNotFound)
render.JSON(w, r, JSON{"error": "not found"})
return
}

if strings.HasPrefix(r.URL.Path, "/static") {
render.Status(r, http.StatusNotFound)
return
}

s.render(w, http.StatusNotFound, "404.tmpl.html", baseTmpl, "not found")
})

router.Get("/", s.indexCtrl)

router.Post("/secure-link", s.generateLink)
router.Get("/message/{key}", s.showMessageView)
router.Post("/load-message", s.loadMessage)

// TODO: disable static directory listing
s.fileServer(router, "/", http.Dir(s.WebRoot))

return router
}

Expand Down Expand Up @@ -171,17 +197,16 @@ func (s Server) getParamsCtrl(w http.ResponseWriter, r *http.Request) {
func (s Server) fileServer(r chi.Router, path string, root http.FileSystem) {
log.Printf("[INFO] run file server for %s", root)
fs := http.StripPrefix(path, http.FileServer(root))
if path != "/" && path[len(path)-1] != '/' {
r.Get(path, http.RedirectHandler(path+"/", http.StatusMovedPermanently).ServeHTTP)
path += "/"
}

path += "*"

r.With(um.Rewrite("/show/(.*)", "/show/?$1")).Get(path, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != "/show/" {
r.Get(path, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/") && len(r.URL.Path) > 1 && r.URL.Path != "/static/" {
http.NotFound(w, r)
return
}
fs.ServeHTTP(w, r)
})

r.Handle("/static/*", http.StripPrefix("/static", fs))
}
232 changes: 232 additions & 0 deletions backend/app/server/templates.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package server

import (
"bytes"
"fmt"
"github.com/go-chi/chi/v5"
log "github.com/go-pkgz/lgr"
"github.com/pkg/errors"
"github.com/umputun/secrets/backend/app/messager"
"github.com/umputun/secrets/backend/app/store"
"github.com/umputun/secrets/backend/app/validator"
"net/http"
"strconv"
"time"
)

const (
baseTmpl = "base"
mainTmpl = "main"
errorTmpl = "error"

msgKey = "message"
pinKey = "pin"
expKey = "exp"
expUnitKey = "exp-unit"
keyKey = "key"
)

type createMsgForm struct {
Message string
Pin string
Exp int
ExpUnit string
validator.Validator
}

type showMsgForm struct {
Key string
Pin string
Message string
validator.Validator
}

type templateData struct {
Form any
PinSize int
}

func (s Server) render(w http.ResponseWriter, status int, page string, tmplName string, data any) {
ts, ok := s.TemplateCache[page]
if !ok {
err := fmt.Errorf("the template %s does not exist", page)
log.Printf("[ERROR] %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}

buf := new(bytes.Buffer)

if tmplName == "" {
tmplName = baseTmpl
}
err := ts.ExecuteTemplate(buf, tmplName, data)
if err != nil {
log.Printf("[ERROR] %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}

w.WriteHeader(status)
_, err = buf.WriteTo(w)
if err != nil {
log.Printf("[ERROR] %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}

// renders the home page
func (s Server) indexCtrl(w http.ResponseWriter, r *http.Request) {
data := templateData{
Form: createMsgForm{},
PinSize: s.PinSize,
}
s.render(w, http.StatusOK, "home.tmpl.html", baseTmpl, data)
}

// renders the generate link page
func (s Server) generateLink(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
s.render(w, http.StatusOK, "error.tmpl.html", errorTmpl, err.Error())
return
}

exp := r.PostFormValue(expKey)

form := createMsgForm{
Message: r.PostForm.Get(msgKey),
Pin: r.PostForm.Get(pinKey),
ExpUnit: r.PostForm.Get(expUnitKey),
}

form.CheckField(validator.NotBlank(form.Message), msgKey, "Message can't be empty")
form.CheckField(validator.NotBlank(form.Pin), pinKey, fmt.Sprintf("Pin must be %d characters long", s.PinSize))
form.CheckField(validator.MinChars(form.Pin, s.PinSize), pinKey, fmt.Sprintf("Pin must be %d characters long", s.PinSize))
form.CheckField(validator.MaxChars(form.Pin, s.PinSize), pinKey, fmt.Sprintf("Pin must be %d characters long", s.PinSize))
form.CheckField(validator.NotBlank(exp), expKey, "Expire can't be empty")
form.CheckField(validator.IsNumber(exp), expKey, "Expire must be a number")

expInt, _ := strconv.Atoi(exp)
form.Exp = expInt
expDuration := duration(expInt, r.PostFormValue(expUnitKey))

form.CheckField(validator.MaxDuration(expDuration, s.MaxExpire), expKey, fmt.Sprintf("Expire must be less than %s", humanDuration(s.MaxExpire)))

if !form.Valid() {
data := templateData{
Form: form,
PinSize: s.PinSize,
}

s.render(w, http.StatusOK, "home.tmpl.html", mainTmpl, data)
return
}

msg, err := s.Messager.MakeMessage(time.Minute*time.Duration(expInt), form.Message, form.Pin)
if err != nil {
s.render(w, http.StatusOK, "secure-link.tmpl.html", errorTmpl, err.Error())
return
}

msgUrl := fmt.Sprintf("http://%s/message/%s", s.Domain, msg.Key)

data := struct {
Url string
Expire string
}{
Url: msgUrl,
Expire: msg.Exp.Format("2006-01-02 15:04:05"),
}

s.render(w, http.StatusOK, "secure-link.tmpl.html", "secure-link", data)
}

// renders the show decoded message page
func (s Server) showMessageView(w http.ResponseWriter, r *http.Request) {
key := chi.URLParam(r, keyKey)

data := templateData{
Form: showMsgForm{
Key: key,
},
PinSize: s.PinSize,
}

s.render(w, http.StatusOK, "show-message.tmpl.html", baseTmpl, data)
}

func (s Server) loadMessage(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
s.render(w, http.StatusOK, "error.tmpl.html", errorTmpl, err.Error())
return
}

form := showMsgForm{
Key: r.PostForm.Get("key"),
Pin: r.PostForm.Get("pin"),
}

form.CheckField(validator.NotBlank(form.Pin), "pin", "Pin can't be empty")
form.CheckField(validator.MinChars(form.Pin, s.PinSize), "pin", fmt.Sprintf("Pin must be %d characters long", s.PinSize))
form.CheckField(validator.MaxChars(form.Pin, s.PinSize), "pin", fmt.Sprintf("Pin must be %d characters long", s.PinSize))

if !form.Valid() {
data := templateData{
Form: form,
PinSize: s.PinSize,
}

s.render(w, http.StatusOK, "show-message.tmpl.html", mainTmpl, data)
return
}

msg, err := s.Messager.LoadMessage(form.Key, form.Pin)
if err != nil {
if errors.Is(err, messager.ErrExpired) || errors.Is(err, store.ErrLoadRejected) {
s.render(w, http.StatusOK, "error.tmpl.html", errorTmpl, err.Error())
return
}
log.Printf("[WARN] can't load message %v", err)
form.AddFieldError("pin", err.Error())

data := templateData{
Form: form,
PinSize: s.PinSize,
}

s.render(w, http.StatusOK, "show-message.tmpl.html", mainTmpl, data)

return
}

s.render(w, http.StatusOK, "decoded-message.tmpl.html", "decoded-message", string(msg.Data))
}

func duration(n int, unit string) time.Duration {
switch unit {
case "m":
return time.Duration(n) * time.Minute
case "h":
return time.Duration(n) * time.Hour
case "d":
return time.Duration(n*24) * time.Hour
default:
return time.Duration(0)
}
}

func humanDuration(d time.Duration) string {
switch {
case d < time.Minute:
return fmt.Sprintf("%d seconds", d/time.Second)
case d < time.Hour:
return fmt.Sprintf("%d minutes", d/time.Minute)
case d < time.Hour*24:
return fmt.Sprintf("%d hours", d/time.Hour)
default:
return fmt.Sprintf("%d days", d/(time.Hour*24))
}
}

0 comments on commit 8523524

Please sign in to comment.