From aef028263229d8acda28b8e657413f7e9c187833 Mon Sep 17 00:00:00 2001 From: Alan Pearce Date: Wed, 22 May 2024 16:48:57 +0200 Subject: refactor: split server and mux --- internal/server/filemap.go | 77 -------------------- internal/server/mime.go | 22 ++++++ internal/server/server.go | 166 ++++++++------------------------------------ internal/website/filemap.go | 77 ++++++++++++++++++++ internal/website/mux.go | 119 +++++++++++++++++++++++++++++++ 5 files changed, 247 insertions(+), 214 deletions(-) delete mode 100644 internal/server/filemap.go create mode 100644 internal/server/mime.go create mode 100644 internal/website/filemap.go create mode 100644 internal/website/mux.go (limited to 'internal') diff --git a/internal/server/filemap.go b/internal/server/filemap.go deleted file mode 100644 index 6130e65..0000000 --- a/internal/server/filemap.go +++ /dev/null @@ -1,77 +0,0 @@ -package server - -import ( - "fmt" - "hash/fnv" - "io" - "io/fs" - "os" - "path/filepath" - "strings" - - "website/internal/log" - - "github.com/pkg/errors" -) - -type File struct { - filename string - etag string -} - -var files = map[string]File{} - -func hashFile(filename string) (string, error) { - f, err := os.Open(filename) - if err != nil { - return "", err - } - defer f.Close() - hash := fnv.New64a() - if _, err := io.Copy(hash, f); err != nil { - return "", err - } - return fmt.Sprintf(`W/"%x"`, hash.Sum(nil)), nil -} - -func registerFile(urlpath string, filepath string) error { - if files[urlpath] != (File{}) { - log.Info("registerFile called with duplicate file", "url_path", urlpath) - return nil - } - hash, err := hashFile(filepath) - if err != nil { - return err - } - files[urlpath] = File{ - filename: filepath, - etag: hash, - } - return nil -} - -func registerContentFiles(root string) error { - err := filepath.WalkDir(root, func(filePath string, f fs.DirEntry, err error) error { - if err != nil { - return errors.WithMessagef(err, "failed to access path %s", filePath) - } - relPath, err := filepath.Rel(root, filePath) - if err != nil { - return errors.WithMessagef(err, "failed to make path relative, path: %s", filePath) - } - urlPath, _ := strings.CutSuffix(relPath, "index.html") - if !f.IsDir() { - log.Debug("registering file", "urlpath", "/"+urlPath) - return registerFile("/"+urlPath, filePath) - } - return nil - }) - if err != nil { - return err - } - return nil -} - -func GetFile(urlPath string) File { - return files[urlPath] -} diff --git a/internal/server/mime.go b/internal/server/mime.go new file mode 100644 index 0000000..696a0ad --- /dev/null +++ b/internal/server/mime.go @@ -0,0 +1,22 @@ +package server + +import ( + "mime" + "website/internal/log" +) + +var newMIMEs = map[string]string{ + ".xsl": "text/xsl", +} + +func fixupMIMETypes() { + for ext, newType := range newMIMEs { + if err := mime.AddExtensionType(ext, newType); err != nil { + log.Error("could not update mime type", "ext", ext, "mime", newType) + } + } +} + +func init() { + fixupMIMETypes() +} diff --git a/internal/server/server.go b/internal/server/server.go index 97851f0..4fda3dc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,21 +2,17 @@ package server import ( "context" - "encoding/json" "fmt" - "mime" "net" "net/http" "os" - "path" "slices" - "strings" "time" cfg "website/internal/config" "website/internal/log" + "website/internal/website" - "github.com/benpate/digit" "github.com/getsentry/sentry-go" sentryhttp "github.com/getsentry/sentry-go/http" "github.com/pkg/errors" @@ -25,8 +21,9 @@ import ( var config *cfg.Config var ( - CommitSHA string - ShortSHA string + CommitSHA string = "local" + ShortSHA string = "local" + serverHeader string = fmt.Sprintf("website (%s)", ShortSHA) ) type Config struct { @@ -38,91 +35,23 @@ type Config struct { BaseURL cfg.URL `conf:"default:http://localhost:3000,short:b"` } -type HTTPError struct { - Error error - Message string - Code int -} - type Server struct { *http.Server } -func canonicalisePath(path string) (cPath string, differs bool) { - cPath = path - if strings.HasSuffix(path, "/index.html") { - cPath, differs = strings.CutSuffix(path, "index.html") - } else if !strings.HasSuffix(path, "/") && files[path+"/"] != (File{}) { - cPath, differs = path+"/", true - } - return cPath, differs -} - -func serveFile(w http.ResponseWriter, r *http.Request) *HTTPError { - urlPath, shouldRedirect := canonicalisePath(r.URL.Path) - if shouldRedirect { - http.Redirect(w, r, urlPath, 302) - return nil - } - file := GetFile(urlPath) - if file == (File{}) { - return &HTTPError{ - Message: "File not found", - Code: http.StatusNotFound, - } - } - w.Header().Add("ETag", file.etag) - w.Header().Add("Vary", "Accept-Encoding") - w.Header().Add("Content-Security-Policy", config.CSP.String()) - for k, v := range config.Extra.Headers { - w.Header().Add(k, v) - } - - http.ServeFile(w, r, files[urlPath].filename) - return nil -} - -type webHandler func(http.ResponseWriter, *http.Request) *HTTPError - -func (fn webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - defer func() { - if fail := recover(); fail != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Error("runtime panic!", "error", fail) - } - }() - w.Header().Set("Server", fmt.Sprintf("website (%s)", ShortSHA)) - if err := fn(w, r); err != nil { - if strings.Contains(r.Header.Get("Accept"), "text/html") { - w.WriteHeader(err.Code) - notFoundPage := "website/private/404.html" - http.ServeFile(w, r, notFoundPage) - } else { - http.Error(w, err.Message, err.Code) - } - } -} - -var newMIMEs = map[string]string{ - ".xsl": "text/xsl", -} - -func fixupMIMETypes() { - for ext, newType := range newMIMEs { - if err := mime.AddExtensionType(ext, newType); err != nil { - log.Error("could not update mime type", "ext", ext, "mime", newType) - } - } -} - func applyDevModeOverrides(config *cfg.Config) { config.CSP.ScriptSrc = slices.Insert(config.CSP.ScriptSrc, 0, "'unsafe-inline'") config.CSP.ConnectSrc = slices.Insert(config.CSP.ConnectSrc, 0, "'self'") } -func New(runtimeConfig *Config) (*Server, error) { - fixupMIMETypes() +func serverHeaderHandler(wrappedHandler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Server", serverHeader) + wrappedHandler.ServeHTTP(w, r) + }) +} +func New(runtimeConfig *Config) (*Server, error) { var err error config, err = cfg.GetConfig() if err != nil { @@ -132,13 +61,6 @@ func New(runtimeConfig *Config) (*Server, error) { applyDevModeOverrides(config) } - prefix := path.Join(runtimeConfig.Root, "public") - log.Debug("registering content files", "prefix", prefix) - err = registerContentFiles(prefix) - if err != nil { - return nil, errors.WithMessagef(err, "registering content files") - } - env := "development" if runtimeConfig.Production { env = "production" @@ -159,56 +81,20 @@ func New(runtimeConfig *Config) (*Server, error) { }) top := http.NewServeMux() - mux := http.NewServeMux() + mux, err := website.NewMux(config, runtimeConfig.Root) + if err != nil { + return nil, err + } log.Debug("binding main handler to", "host", runtimeConfig.BaseURL.Hostname()+"/") hostname := runtimeConfig.BaseURL.Hostname() - mux.Handle(hostname+"/", webHandler(serveFile)) - - var acctResource = "acct:" + config.Email - me := digit.NewResource(acctResource). - Link("http://openid.net/specs/connect/1.0/issuer", "", config.OIDCHost.String()) - mux.HandleFunc(hostname+"/.well-known/webfinger", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("resource") == acctResource { - obj, err := json.Marshal(me) - if err != nil { - http.Error( - w, - http.StatusText(http.StatusInternalServerError), - http.StatusInternalServerError, - ) - - return - } - - w.Header().Add("Content-Type", "application/jrd+json") - w.Header().Add("Access-Control-Allow-Origin", "*") - _, err = w.Write(obj) - if err != nil { - log.Warn("error writing webfinger request", "error", err) - } - } - }) - const oidcPath = "/.well-known/openid-configuration" - mux.HandleFunc( - hostname+oidcPath, - func(w http.ResponseWriter, r *http.Request) { - u := config.OIDCHost.JoinPath(oidcPath) - http.Redirect(w, r, u.String(), 302) - }) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - newURL := runtimeConfig.BaseURL.String() + r.URL.String() - http.Redirect(w, r, newURL, 301) + + top.Handle(hostname+"/", mux) + + top.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + newURL := runtimeConfig.BaseURL.JoinPath(r.URL.String()) + http.Redirect(w, r, newURL.String(), 301) }) - top.Handle("/", - sentryHandler.Handle( - wrapHandlerWithLogging(mux, wrappedHandlerOptions{ - defaultHostname: runtimeConfig.BaseURL.Hostname(), - }), - ), - ) - // no logging, no sentry top.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) @@ -216,8 +102,14 @@ func New(runtimeConfig *Config) (*Server, error) { listenAddress := net.JoinHostPort(runtimeConfig.ListenAddress, runtimeConfig.Port) return &Server{ &http.Server{ - Addr: listenAddress, - Handler: top, + Addr: listenAddress, + Handler: sentryHandler.Handle( + serverHeaderHandler( + wrapHandlerWithLogging(top, wrappedHandlerOptions{ + defaultHostname: runtimeConfig.BaseURL.Hostname(), + }), + ), + ), }, }, nil } diff --git a/internal/website/filemap.go b/internal/website/filemap.go new file mode 100644 index 0000000..6daeb18 --- /dev/null +++ b/internal/website/filemap.go @@ -0,0 +1,77 @@ +package website + +import ( + "fmt" + "hash/fnv" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "website/internal/log" + + "github.com/pkg/errors" +) + +type File struct { + filename string + etag string +} + +var files = map[string]File{} + +func hashFile(filename string) (string, error) { + f, err := os.Open(filename) + if err != nil { + return "", err + } + defer f.Close() + hash := fnv.New64a() + if _, err := io.Copy(hash, f); err != nil { + return "", err + } + return fmt.Sprintf(`W/"%x"`, hash.Sum(nil)), nil +} + +func registerFile(urlpath string, filepath string) error { + if files[urlpath] != (File{}) { + log.Info("registerFile called with duplicate file", "url_path", urlpath) + return nil + } + hash, err := hashFile(filepath) + if err != nil { + return err + } + files[urlpath] = File{ + filename: filepath, + etag: hash, + } + return nil +} + +func registerContentFiles(root string) error { + err := filepath.WalkDir(root, func(filePath string, f fs.DirEntry, err error) error { + if err != nil { + return errors.WithMessagef(err, "failed to access path %s", filePath) + } + relPath, err := filepath.Rel(root, filePath) + if err != nil { + return errors.WithMessagef(err, "failed to make path relative, path: %s", filePath) + } + urlPath, _ := strings.CutSuffix(relPath, "index.html") + if !f.IsDir() { + log.Debug("registering file", "urlpath", "/"+urlPath) + return registerFile("/"+urlPath, filePath) + } + return nil + }) + if err != nil { + return err + } + return nil +} + +func GetFile(urlPath string) File { + return files[urlPath] +} diff --git a/internal/website/mux.go b/internal/website/mux.go new file mode 100644 index 0000000..0335f97 --- /dev/null +++ b/internal/website/mux.go @@ -0,0 +1,119 @@ +package website + +import ( + "encoding/json" + "net/http" + "path" + "strings" + "website/internal/config" + "website/internal/log" + + "github.com/benpate/digit" + "github.com/pkg/errors" +) + +type HTTPError struct { + Error error + Message string + Code int +} + +func canonicalisePath(path string) (cPath string, differs bool) { + cPath = path + if strings.HasSuffix(path, "/index.html") { + cPath, differs = strings.CutSuffix(path, "index.html") + } else if !strings.HasSuffix(path, "/") && files[path+"/"] != (File{}) { + cPath, differs = path+"/", true + } + return cPath, differs +} + +type webHandler func(http.ResponseWriter, *http.Request) *HTTPError + +func (fn webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer func() { + if fail := recover(); fail != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Error("runtime panic!", "error", fail) + } + }() + if err := fn(w, r); err != nil { + if strings.Contains(r.Header.Get("Accept"), "text/html") { + w.WriteHeader(err.Code) + notFoundPage := "website/private/404.html" + http.ServeFile(w, r, notFoundPage) + } else { + http.Error(w, err.Message, err.Code) + } + } +} + +func NewMux(cfg *config.Config, root string) (mux *http.ServeMux, err error) { + mux = &http.ServeMux{} + + prefix := path.Join(root, "public") + log.Debug("registering content files", "prefix", prefix) + err = registerContentFiles(prefix) + if err != nil { + return nil, errors.WithMessagef(err, "registering content files") + } + + mux.Handle("/", webHandler(func(w http.ResponseWriter, r *http.Request) *HTTPError { + urlPath, shouldRedirect := canonicalisePath(r.URL.Path) + if shouldRedirect { + http.Redirect(w, r, urlPath, 302) + return nil + } + file := GetFile(urlPath) + if file == (File{}) { + return &HTTPError{ + Message: "File not found", + Code: http.StatusNotFound, + } + } + w.Header().Add("ETag", file.etag) + w.Header().Add("Vary", "Accept-Encoding") + w.Header().Add("Content-Security-Policy", cfg.CSP.String()) + for k, v := range cfg.Extra.Headers { + w.Header().Add(k, v) + } + + http.ServeFile(w, r, files[urlPath].filename) + return nil + })) + + var acctResource = "acct:" + cfg.Email + me := digit.NewResource(acctResource). + Link("http://openid.net/specs/connect/1.0/issuer", "", cfg.OIDCHost.String()) + + mux.HandleFunc("/.well-known/webfinger", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("resource") == acctResource { + obj, err := json.Marshal(me) + if err != nil { + http.Error( + w, + http.StatusText(http.StatusInternalServerError), + http.StatusInternalServerError, + ) + + return + } + + w.Header().Add("Content-Type", "application/jrd+json") + w.Header().Add("Access-Control-Allow-Origin", "*") + _, err = w.Write(obj) + if err != nil { + log.Warn("error writing webfinger request", "error", err) + } + } + }) + const oidcPath = "/.well-known/openid-configuration" + mux.HandleFunc( + oidcPath, + func(w http.ResponseWriter, r *http.Request) { + u := cfg.OIDCHost.JoinPath(oidcPath) + http.Redirect(w, r, u.String(), 302) + }) + + return mux, nil +} -- cgit 1.4.1