package api import ( "fmt" "net/http" "time" ) // Buckets a report may be cut into. A whitelist rather than passing the string // through: date_trunc takes a text argument, so an unchecked value is either a // database error surfaced to a shop floor or, in a query built by // concatenation, something much worse. var buckets = map[string]bool{ "hour": true, "day": true, "week": true, "month": true, } // maxWindow bounds a report at two years. Not for safety - for honesty: an // open-ended range on a 2 vCPU box times out at the proxy and the user sees a // blank screen with no explanation. const maxWindow = 2 * 366 * 24 * time.Hour func (s *Server) reportQuery(r *http.Request) (ReportQuery, error) { p := PrincipalFrom(r.Context()) q := r.URL.Query() // The tenant comes from the session. A client_id parameter would be a // cross-tenant read waiting for somebody to try it. out := ReportQuery{ClientID: p.ClientID} // A shop may be named by uuid or by its slug. Resolved here, where an // unknown one is a 400 the caller can read, rather than in Postgres where // a malformed uuid is a cast error and surfaces as a 500. if raw := siteParam(r); raw != "" { id, err := s.siteIDFor(r.Context(), p.ClientID, raw) if err != nil { return out, fmt.Errorf("could not look up that shop: %w", err) } if id == "" { return out, fmt.Errorf("no shop called %q", raw) } out.SiteID = id } now := s.now() from, err := parseDay(q.Get("from"), now.AddDate(0, 0, -29)) if err != nil { return out, fmt.Errorf("`from` is not a date: %w", err) } to, err := parseDay(q.Get("to"), now) if err != nil { return out, fmt.Errorf("`to` is not a date: %w", err) } // `to` is inclusive to the user ("1st to the 7th" includes the 7th) and // exclusive in SQL. Doing that conversion in one place is the difference // between a report that quietly misses its own last day and one that does // not. if len(trim(q.Get("to"))) == 10 { to = to.AddDate(0, 0, 1) } if !to.After(from) { return out, fmt.Errorf("`to` must be after `from`") } if to.Sub(from) > maxWindow { return out, fmt.Errorf("that range is longer than two years - " + "please narrow it") } out.From, out.To = from, to out.Bucket = trim(q.Get("bucket")) if out.Bucket == "" { out.Bucket = "day" } if !buckets[out.Bucket] { return out, fmt.Errorf("bucket must be hour, day, week or month") } out.Timezone = trim(q.Get("tz")) if out.Timezone == "" { out.Timezone = "UTC" } // Validated here, where a bad name is a 400 the user can fix, rather than // in Postgres where it is a 500. if _, err := time.LoadLocation(out.Timezone); err != nil { return out, fmt.Errorf("unknown timezone %q", out.Timezone) } return out, nil } // parseDay accepts a plain date or a full RFC3339 timestamp. Shop staff type // dates; the desktop app sends timestamps. func parseDay(s string, def time.Time) (time.Time, error) { s = trim(s) if s == "" { return def.UTC(), nil } if len(s) == 10 { return time.Parse("2006-01-02", s) } t, err := time.Parse(time.RFC3339, s) return t.UTC(), err } func (s *Server) handleFootfall(w http.ResponseWriter, r *http.Request) { q, err := s.reportQuery(r) if err != nil { badRequest(w, err.Error()) return } points, totals, err := s.Store.Footfall(r.Context(), q) if err != nil { s.serverError(w, "footfall", err) return } writeJSON(w, http.StatusOK, FootfallReport{ From: q.From.Format(time.RFC3339), To: q.To.Format(time.RFC3339), Bucket: q.Bucket, TZ: q.Timezone, Points: points, Total: totals.UniqueVisitors, Visits: totals.Visits, FractionBelowGate: totals.FractionBelowGate, WorstSite: totals.WorstSite, }) } func (s *Server) handleConversion(w http.ResponseWriter, r *http.Request) { q, err := s.reportQuery(r) if err != nil { badRequest(w, err.Error()) return } rep, err := s.Store.Conversion(r.Context(), q) if err != nil { s.serverError(w, "conversion", err) return } writeJSON(w, http.StatusOK, rep) } func (s *Server) handleSites(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) sites, err := s.Store.SiteHealth(r.Context(), p.ClientID) if err != nil { s.serverError(w, "site health", err) return } if sites == nil { // A JSON null would make every caller handle two empty cases. sites = []SiteHealth{} } writeJSON(w, http.StatusOK, sites) }