diff options
Diffstat (limited to 'cmd')
-rw-r--r-- | cmd/server/server.go | 363 |
1 files changed, 238 insertions, 125 deletions
diff --git a/cmd/server/server.go b/cmd/server/server.go index cc1821a..39feb86 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -1,31 +1,26 @@ package main import ( - "errors" "fmt" + "hash/fnv" "io" + "io/fs" "log" + "log/slog" + "mime" "net" "net/http" "os" + "path/filepath" + "strings" "time" cfg "website/internal/config" - "github.com/ansrivas/fiberprometheus/v2" "github.com/ardanlabs/conf/v3" "github.com/getsentry/sentry-go" - "github.com/gofiber/contrib/fibersentry" - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/cache" - "github.com/gofiber/fiber/v2/middleware/compress" - "github.com/gofiber/fiber/v2/middleware/etag" - "github.com/gofiber/fiber/v2/middleware/filesystem" - "github.com/gofiber/fiber/v2/middleware/healthcheck" - "github.com/gofiber/fiber/v2/middleware/logger" - "github.com/gofiber/fiber/v2/middleware/recover" - "github.com/gofiber/fiber/v2/middleware/skip" - + sentryhttp "github.com/getsentry/sentry-go/http" + "github.com/pkg/errors" "github.com/shengyanli1982/law" ) @@ -37,151 +32,269 @@ type Config struct { RedirectOtherHostnames bool `conf:"default:false"` } -// TODO purge CSS -// TODO HTTP2 https://github.com/dgrr/http2 +var Commit string + +var config *cfg.Config -type Host struct { - Fiber *fiber.App +type File struct { + filename string + etag string } -var Commit 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.Printf("registerFile called with duplicate file, urlPath: %s", 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() { + slog.Debug("registering file", "urlpath", "/"+urlPath) + return registerFile("/"+urlPath, filePath) + } + return nil + }) + if err != nil { + return err + } + return nil +} + +type HTTPError struct { + Error error + Message string + Code int +} + +func canonicalisePath(path string) (cPath string, differs bool) { + 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 path, 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 := files[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") + 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) + slog.Error("runtime panic!", "error", fail) + } + }() + w.Header().Set("Server", fmt.Sprintf("website (%s)", Commit)) + 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 { + slog.Error("could not update mime type", "ext", ext, "mime", newType) + } + } +} + +type loggingResponseWriter struct { + http.ResponseWriter + statusCode int +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + // avoids warning: superfluous response.WriteHeader call + if lrw.statusCode != http.StatusOK { + lrw.ResponseWriter.WriteHeader(code) + } +} + +func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter { + return &loggingResponseWriter{w, http.StatusOK} +} + +type wrappedHandlerOptions struct { + defaultHostname string + logger io.Writer +} + +func wrapHandlerWithLogging(wrappedHandler http.Handler, opts wrappedHandlerOptions) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + scheme := r.Header.Get("X-Forwarded-Proto") + if scheme == "" { + scheme = "http" + } + host := r.Header.Get("Host") + if host == "" { + host = opts.defaultHostname + } + lw := NewLoggingResponseWriter(w) + wrappedHandler.ServeHTTP(lw, r) + statusCode := lw.statusCode + fmt.Fprintf( + opts.logger, + "%s %s %d %s %s %s\n", + scheme, + r.Method, + statusCode, + host, + r.URL.Path, + lw.Header().Get("Location"), + ) + }) +} func main() { + if os.Getenv("DEBUG") != "" { + slog.SetLogLoggerLevel(slog.LevelDebug) + } + + fixupMIMETypes() + runtimeConfig := Config{} - if help, err := conf.Parse("", &runtimeConfig); err != nil { + help, err := conf.Parse("", &runtimeConfig) + if err != nil { if errors.Is(err, conf.ErrHelpWanted) { fmt.Println(help) os.Exit(1) } log.Panicf("parsing runtime configuration: %v", err) } - config, err := cfg.GetConfig() + + config, err = cfg.GetConfig() if err != nil { log.Panicf("parsing configuration file: %v", err) } + cwd, err := os.Getwd() + if err != nil { + log.Panicf("don't know where I am") + } + slog.Debug("starting at", "wd", cwd) + + prefix := "website/public" + slog.Debug("registering content files", "prefix", prefix) + err = registerContentFiles(prefix) + if err != nil { + log.Panicf("registering content files: %v", err) + } + + env := "development" + if runtimeConfig.Production { + env = "production" + } err = sentry.Init(sentry.ClientOptions{ - Dsn: os.Getenv("SENTRY_DSN"), - Release: os.Getenv("FLY_MACHINE_VERSION"), - Environment: os.Getenv("ENV"), + EnableTracing: true, + TracesSampleRate: 1.0, + Dsn: os.Getenv("SENTRY_DSN"), + Release: Commit, + Environment: env, }) if err != nil { log.Panic("could not set up sentry") } defer sentry.Flush(2 * time.Second) - - metricServer := fiber.New(fiber.Config{ - GETOnly: true, - StrictRouting: true, - DisableDefaultDate: true, - DisableHeaderNormalizing: true, - DisableStartupMessage: true, - Network: fiber.NetworkTCP, - }) - prometheus := fiberprometheus.New("homestead") - prometheus.RegisterAt(metricServer, "/metrics") - - hosts := map[string]*Host{} - - internal := fiber.New(fiber.Config{ - GETOnly: true, - StrictRouting: true, - }) - internal.Use(healthcheck.New(healthcheck.Config{})) - hosts["fly-internal"] = &Host{internal} - - website := fiber.New(fiber.Config{ - EnableTrustedProxyCheck: true, - TrustedProxies: []string{"172.16.0.0/16"}, - ProxyHeader: "Fly-Client-IP", - GETOnly: true, - ReadTimeout: 5 * time.Minute, - WriteTimeout: 5 * time.Minute, - StrictRouting: true, - UnescapePath: true, - }) - - website.Use(prometheus.Middleware) - website.Use(fibersentry.New(fibersentry.Config{})) - website.Use(func(c *fiber.Ctx) error { - for k, v := range config.Extra.Headers { - c.Set(k, v) - } - if c.Secure() { - c.Set("Strict-Transport-Security", "max-age=31536000; includeSubdomains; preload") - } - return c.Next() + sentryHandler := sentryhttp.New(sentryhttp.Options{ + Repanic: true, }) - website.Use(compress.New()) - website.Use(cache.New(cache.Config{ - CacheControl: true, - Expiration: 24 * time.Hour, - StoreResponseHeaders: true, - })) - // must be after compress to be encoding-independent - website.Use(etag.New(etag.Config{ - Weak: true, - })) + mux := http.NewServeMux() + slog.Debug("binding main handler to", "host", runtimeConfig.BaseURL.Hostname()+"/") + mux.Handle(runtimeConfig.BaseURL.Hostname()+"/", webHandler(serveFile)) - website.Use(recover.New(recover.Config{})) - - prefix := "website/public" - publicFiles := http.Dir(prefix) - website.Use("/", filesystem.New(filesystem.Config{ - Root: publicFiles, - ContentTypeCharset: "utf-8", - MaxAge: int((24 * time.Hour).Seconds()), - })) - website.Use(func(c *fiber.Ctx) error { - c.Status(fiber.StatusNotFound).Type("html", "utf-8") - content, err := os.Open("website/private/404.html") - if err != nil { - c.Type("txt") - return c.SendString("404 Not Found") - } - return c.SendStream(content) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + newURL := runtimeConfig.BaseURL.String() + r.URL.String() + http.Redirect(w, r, newURL, 301) }) - hosts[runtimeConfig.BaseURL.Host] = &Host{website} - toplevel := fiber.New(fiber.Config{ - DisableStartupMessage: runtimeConfig.Production, - ServerHeader: fmt.Sprintf("website (%s)", Commit), - Network: fiber.NetworkTCP, - }) - toplevel.Get("/health", func(c *fiber.Ctx) error { - return c.SendStatus(fiber.StatusOK) - }) var logWriter io.Writer if runtimeConfig.Production { logWriter = law.NewWriteAsyncer(os.Stdout, nil) } else { logWriter = os.Stdout } - toplevel.Use(skip.New(logger.New(logger.Config{ - Output: logWriter, - Format: "${protocol} ${method} ${status} ${host} ${url} ${respHeader:Location}\n", - }), func(c *fiber.Ctx) bool { - return c.Hostname() == "fly-internal" - })) - toplevel.Use(func(c *fiber.Ctx) error { - host := hosts[c.Hostname()] - if host == nil { - if runtimeConfig.RedirectOtherHostnames { - return c.Redirect(runtimeConfig.BaseURL.JoinPath(c.OriginalURL()).String()) - } else { - hosts[runtimeConfig.BaseURL.Host].Fiber.Handler()(c.Context()) - return nil - } - } else { - host.Fiber.Handler()(c.Context()) - return nil - } + http.Handle("/", + sentryHandler.Handle( + wrapHandlerWithLogging(mux, wrappedHandlerOptions{ + defaultHostname: runtimeConfig.BaseURL.Hostname(), + logger: logWriter, + }), + ), + ) + // no logging, no sentry + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) }) - go func() { - err := metricServer.Listen(net.JoinHostPort(runtimeConfig.ListenAddress, "9091")) - log.Printf("failed to start metrics server: %v", err) - }() - log.Fatal(toplevel.Listen(net.JoinHostPort(runtimeConfig.ListenAddress, fmt.Sprint(runtimeConfig.Port)))) + listenAddress := net.JoinHostPort(runtimeConfig.ListenAddress, fmt.Sprint(runtimeConfig.Port)) + log.Fatal(http.ListenAndServe(listenAddress, nil)) } |