This commit is contained in:
jdl
2024-11-11 06:36:55 +01:00
parent d0587cc585
commit c5419d662e
102 changed files with 4181 additions and 0 deletions

5
webutil/README.md Normal file
View File

@@ -0,0 +1,5 @@
# webutil
## Roadmap
* logging middleware

10
webutil/go.mod Normal file
View File

@@ -0,0 +1,10 @@
module git.crumpington.com/lib/webutil
go 1.23.2
require golang.org/x/crypto v0.28.0
require (
golang.org/x/net v0.21.0 // indirect
golang.org/x/text v0.19.0 // indirect
)

6
webutil/go.sum Normal file
View File

@@ -0,0 +1,6 @@
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=

24
webutil/listenandserve.go Normal file
View File

@@ -0,0 +1,24 @@
package webutil
import (
"errors"
"net/http"
"strings"
"golang.org/x/crypto/acme/autocert"
)
// Serve requests using the given http.Server. If srv.Addr has the format
// `hostname:https`, then use autocert to manage certificates for the domain.
//
// For http on port 80, you can use :http.
func ListenAndServe(srv *http.Server) error {
if strings.HasSuffix(srv.Addr, ":https") {
hostname := strings.TrimSuffix(srv.Addr, ":https")
if len(hostname) == 0 {
return errors.New("https requires a hostname")
}
return srv.Serve(autocert.NewListener(hostname))
}
return srv.ListenAndServe()
}

View File

@@ -0,0 +1,47 @@
package webutil
import (
"log"
"net/http"
"os"
"time"
)
var _log = log.New(os.Stderr, "", 0)
type responseWriterWrapper struct {
http.ResponseWriter
httpStatus int
responseSize int
}
func (w *responseWriterWrapper) WriteHeader(status int) {
w.httpStatus = status
w.ResponseWriter.WriteHeader(status)
}
func (w *responseWriterWrapper) Write(b []byte) (int, error) {
if w.httpStatus == 0 {
w.httpStatus = 200
}
w.responseSize += len(b)
return w.ResponseWriter.Write(b)
}
func WithLogging(inner http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
t := time.Now()
wrapper := responseWriterWrapper{w, 0, 0}
inner(&wrapper, r)
_log.Printf("%s \"%s %s %s\" %d %d %v\n",
r.RemoteAddr,
r.Method,
r.URL.Path,
r.Proto,
wrapper.httpStatus,
wrapper.responseSize,
time.Since(t),
)
}
}

100
webutil/template.go Normal file
View File

@@ -0,0 +1,100 @@
package webutil
import (
"embed"
"html/template"
"io/fs"
"log"
"path"
"strings"
)
// ParseTemplateSet parses sets of templates from an embed.FS.
//
// Each directory constitutes a set of templates that are parsed together.
//
// Structure (within a directory):
// - share/* are always parsed.
// - base.html will be parsed with each other file in same dir
//
// Call a template with m[path].Execute(w, data) (root dir name is excluded).
//
// For example, if you have
// - /user/share/*
// - /user/base.html
// - /user/home.html
//
// Then you call m["/user/home.html"].Execute(w, data).
func ParseTemplateSet(funcs template.FuncMap, fs embed.FS) map[string]*template.Template {
m := map[string]*template.Template{}
rootDir := readDir(fs, ".")[0].Name()
loadTemplateDir(fs, funcs, m, rootDir, rootDir)
return m
}
func loadTemplateDir(
fs embed.FS,
funcs template.FuncMap,
m map[string]*template.Template,
dirPath string,
rootDir string,
) map[string]*template.Template {
t := template.New("")
if funcs != nil {
t = t.Funcs(funcs)
}
shareDir := path.Join(dirPath, "share")
if _, err := fs.ReadDir(shareDir); err == nil {
log.Printf("Parsing %s...", path.Join(shareDir, "*"))
t = template.Must(t.ParseFS(fs, path.Join(shareDir, "*")))
}
if data, _ := fs.ReadFile(path.Join(dirPath, "base.html")); data != nil {
log.Printf("Parsing %s...", path.Join(dirPath, "base.html"))
t = template.Must(t.Parse(string(data)))
}
for _, ent := range readDir(fs, dirPath) {
if ent.Type().IsDir() {
if ent.Name() != "share" {
m = loadTemplateDir(fs, funcs, m, path.Join(dirPath, ent.Name()), rootDir)
}
continue
}
if !ent.Type().IsRegular() {
continue
}
if ent.Name() == "base.html" {
continue
}
filePath := path.Join(dirPath, ent.Name())
log.Printf("Parsing %s...", filePath)
key := strings.TrimPrefix(path.Join(dirPath, ent.Name()), rootDir)
tt := template.Must(t.Clone())
tt = template.Must(tt.Parse(readFile(fs, filePath)))
m[key] = tt
}
return m
}
func readDir(fs embed.FS, dirPath string) []fs.DirEntry {
ents, err := fs.ReadDir(dirPath)
if err != nil {
panic(err)
}
return ents
}
func readFile(fs embed.FS, path string) string {
data, err := fs.ReadFile(path)
if err != nil {
panic(err)
}
return string(data)
}

49
webutil/template_test.go Normal file
View File

@@ -0,0 +1,49 @@
package webutil
import (
"bytes"
"embed"
"html/template"
"strings"
"testing"
)
//go:embed all:test-templates
var testFS embed.FS
func TestParseTemplateSet(t *testing.T) {
funcs := template.FuncMap{"join": strings.Join}
m := ParseTemplateSet(funcs, testFS)
type TestCase struct {
Key string
Data any
Out string
}
cases := []TestCase{
{
Key: "/home.html",
Data: "DATA",
Out: "<p>HOME!</p>",
}, {
Key: "/about.html",
Data: "DATA",
Out: "<p><b>DATA</b></p>",
}, {
Key: "/contact.html",
Data: []string{"a", "b", "c"},
Out: "<p>a,b,c</p>",
},
}
for _, tc := range cases {
b := &bytes.Buffer{}
m[tc.Key].Execute(b, tc.Data)
out := strings.TrimSpace(b.String())
if out != tc.Out {
t.Fatalf("%s != %s", out, tc.Out)
}
}
}

View File

@@ -0,0 +1 @@
{{define "body"}}{{template "bold" .}}{{end}}

View File

@@ -0,0 +1 @@
<p>{{block "body" .}}default{{end}}</p>

View File

@@ -0,0 +1 @@
{{define "body"}}{{join . ","}}{{end}}

View File

@@ -0,0 +1 @@
{{define "body"}}HOME!{{end}}

View File

@@ -0,0 +1 @@
{{define "bold"}}<b>{{.}}</b>{{end}}

View File

@@ -0,0 +1 @@
{{define "italic"}}<i>{{.}}</i>{{end}}