// Package web contains the web-related features: handlers and middleware package web import ( "context" "encoding/json" "fmt" "log" "net" "net/http" "net/url" "os" "os/signal" "regexp" "strconv" "time" "github.com/gorilla/mux" "code.osinet.fr/fgm/lbc/domain" "code.osinet.fr/fgm/lbc/internal" ) type ( ConfigKeyType string Config struct { internal.Config Addr string Base string // Mount point for service WriteTimeout time.Duration } Server struct { BaseContext context.Context *Config *Stats *log.Logger } ) func (c Config) String() string { return fmt.Sprintf("Addr: %s, Base URL: %s, Write Timeout: %v", c.Addr, c.Base, c.WriteTimeout) } const ( ConfigKey ConfigKeyType = "config" // CT is the normalized representation of the content-type header. CT = "Content-Type" // JSON is the normalized representation of the JSON content-type per RFC4627 ยง6. JSON = "application/json; charset=utf-8" PathFB = "/{int1:[0-9]+}/{int2:[0-9]+}/{limit:[0-9]+}/{str1}/{str2}" ) var ( utf8 = regexp.MustCompile("utf-8") acceptJSON = regexp.MustCompile(`(application/json|\*/\*)`) ) func argsFromRequest(r *http.Request) (int1, int2, limit int, str1, str2 string, status int) { var err error v := mux.Vars(r) status = http.StatusAccepted for k, p := range map[string]*int{"int1": &int1, "int2": &int2, "limit": &limit} { sn := v[k] // Cannot fail: route wouldn't match *p, err = strconv.Atoi(sn) if err != nil || *p < 2 { return 0, 0, 0, "", "", http.StatusNotFound } } for k, p := range map[string]*string{"str1": &str1, "str2": &str2} { // Cannot fail: route wouldn't match *p = v[k] } return } func isJSONAccepted(r *http.Request) bool { // Only return to clients accepting UTF-8 or not specifying a charset charset := r.Header.Get("Accept-Charset") if charset != "" && !utf8.MatchString(charset) { return false } // Only return to clients accepting JSON, anything, or not specifying Accept. accept := r.Header.Get("Accept") return accept == "" || acceptJSON.MatchString(accept) } func MakeFizzBuzz(logger *log.Logger, isVerbose bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Can we serve client at all? if !isJSONAccepted(r) { if isVerbose { logger.Println("request from client unable to accept JSON") } http.Error(w, "cannot offer an accepted content type: can only serve UTF-8 JSON", http.StatusNotAcceptable) return } int1, int2, limit, str1, str2, status := argsFromRequest(r) if status >= http.StatusBadRequest { logger.Printf("Invalid URL requested: %s", r.URL) // Dev-targeted actual error http.NotFound(w, r) // No human-targeted details to limit enumeration attacks return } if isVerbose { logger.Printf("request: %s", r.URL.Path) } fbs, err := domain.FizzBuzz(int1, int2, limit, str1, str2) if err != nil { logger.Println(err) // Dev-targeted actual error http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) // Human-targeted error to user agent return } w.Header().Set(CT, JSON) enc := json.NewEncoder(w) enc.SetEscapeHTML(true) // Prevent XSS on str1/str2 enc.SetIndent("", "\t") // Human-readable JSON err = enc.Encode(fbs) if err != nil { logger.Println(err) // Dev-targeted actual error http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) // human-targeted error to user agent return } } } // SetupRouting configures the server route on the specified base path. func SetupRouting(base string, logger *log.Logger, stats *Stats, isVerbose bool) (http.Handler, error) { restBase, err := url.JoinPath("/", base) // Perform base validation, including fixing "" as "/". if err != nil { return nil, fmt.Errorf("failed building the base path: %w", err) } sm := http.NewServeMux() router := mux.NewRouter() sub := router.PathPrefix(restBase). Name("base"). Subrouter() sub.Handle(PathFB, stats.Middleware()(MakeFizzBuzz(logger, isVerbose))). Name("fizzbuzz") sub.Handle("/stats", stats.Handler()) sm.Handle("/", sub) return sm, nil } // Serve starts the HTTP listening loop. func (s *Server) Serve(serverClosed chan domain.Empty) error { // Provide access to the globally injected services in handlers. baseContext := func(listener net.Listener) context.Context { return s.BaseContext } r, err := SetupRouting(s.Base, s.Logger, s.Stats, s.Verbose) if err != nil { s.Logger.Fatalf("failed setting up routing: %v", err) } hs := http.Server{ Addr: s.Addr, BaseContext: baseContext, ErrorLog: s.Logger, Handler: r, IdleTimeout: 100 * time.Millisecond, // Non-zero to improve benchmarks able to reuse connections. MaxHeaderBytes: 8192, // Cf. Apache as industry-standard practice. ReadHeaderTimeout: 10 * time.Millisecond, // 8kB@1Mbps = 8 msec ReadTimeout: 10 * time.Millisecond, // This server has no method expecting a body, so keep this small TLSConfig: nil, WriteTimeout: s.WriteTimeout, // 1Mbps*100msec = 100kB, which should be sufficient for this API } // Setup clean shutdown on signal. go func() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, os.Interrupt) <-sigs if err := hs.Shutdown(context.Background()); err != nil { s.Logger.Printf("web server shutdown: %v", err) } close(serverClosed) }() if s.Verbose { s.Logger.Printf("listening with config %s", s.Config) } go func() { err = hs.ListenAndServe() switch err { case http.ErrServerClosed: if s.Verbose { s.Logger.Printf("http server shut down cleanly") } err = nil default: s.Logger.Printf("http server unexpected error: %v", err) } }() <-serverClosed return err }