package api import ( "context" "net/http" "strconv" "strings" ) // Public references: the names people use for the things this API addresses. // // Every id in the schema is a uuid and stays one. A uuid is the right primary // key here - ids are minted in places that cannot ask a database for the next // value, and eleven tables reference them - but it is the wrong thing to put in // front of a person. "Which customer?" "3446ec35-2c1f-4c8e-9a77-0d1e2f3a4b5c." // Nobody says that, writes it on a card, or reads it back down a phone without // getting it wrong. // // The fix is not a new key. Three of the four things anyone addresses by URL // ALREADY had a human name that this API simply refused to accept: // // site slug "chennai" - in the schema since 001 // camera camera_id "Office1" - and it is what visits.camera_id holds // visitor V- "V-42" - added in 012 // user email - already the login // // So a caller may use either form anywhere an id is taken. A uuid resolves with // no lookup at all, exactly as before; only a non-uuid costs a query. That // keeps this additive: nothing that worked yesterday changes, including every // URL a client has already stored. // // The visitor number is per TENANT, which is what makes it safe to show. A // global sequence would tell any customer who signs up how many people the // whole platform has ever seen, from their own first visitor number. // VisitorRefPrefix is deliberately a letter and a dash rather than bare digits. // It is what makes "V-42" recognisable as a customer rather than an order, a // till or a visit, and it is why a reference pasted into the wrong route fails // to parse instead of quietly matching a different record with that number. const VisitorRefPrefix = "V-" // VisitorRef renders a customer number for display and for URLs. func VisitorRef(number int64) string { if number <= 0 { return "" } return VisitorRefPrefix + strconv.FormatInt(number, 10) } // VisitRefPrefix marks a visit reference. "#" rather than a letter because a // visit is a numbered event, not a named thing, and it reads correctly in a // sentence: "visit #1042 at chennai". const VisitRefPrefix = "#" // VisitRef is what a person quotes for one visit. Empty for a visit recorded // before 014, which had no number - absent rather than wrong. func VisitRef(number int64) string { if number <= 0 { return "" } return VisitRefPrefix + strconv.FormatInt(number, 10) } // ParseVisitorRef accepts "V-42", "v-42" and bare "42". // // Bare digits are accepted because a shop assistant reading a number off a // screen will type the number, and refusing it teaches them to distrust the // field. There is nothing for it to collide with: a uuid is checked first and // is never all digits. func ParseVisitorRef(s string) (int64, bool) { s = strings.TrimSpace(s) if s == "" { return 0, false } if len(s) > 2 && (s[0] == 'V' || s[0] == 'v') && s[1] == '-' { s = s[2:] } n, err := strconv.ParseInt(s, 10, 64) if err != nil || n <= 0 { return 0, false } return n, true } // looksLikeName bounds a slug or camera id before it reaches SQL. Not a // validity check - the lookup decides that - only a guard so a path segment // full of junk is answered as "no such thing" without a round trip. func looksLikeName(s string) bool { if s == "" || len(s) > 64 { return false } for _, c := range s { ok := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' if !ok { return false } } return true } // Each reference has two resolvers: an inner one that answers ("", nil) for // "no such thing", and an outer one that writes the response itself so a call // site stays the same three lines the uuid check was. Both exist because a // query parameter is validated where a 400 is the right answer and a path // segment where a 404 is - splitting them lets one implementation serve both. // // A reference that does not resolve is 404 on a path, never 400, for the reason // the old shape check gave: to the caller, a malformed reference and one that // names nothing are the same thing - it is not there. func (s *Server) visitorIDFor(ctx context.Context, clientID, raw string) (string, error) { if looksLikeUUID(raw) { return raw, nil } number, ok := ParseVisitorRef(raw) if !ok { return "", nil } return s.Store.VisitorIDByNumber(ctx, clientID, number) } func (s *Server) siteIDFor(ctx context.Context, clientID, raw string) (string, error) { if looksLikeUUID(raw) { return raw, nil } if !looksLikeName(raw) { return "", nil } return s.Store.SiteIDBySlug(ctx, clientID, strings.ToLower(raw)) } func (s *Server) cameraIDFor(ctx context.Context, clientID, raw string) (string, error) { if looksLikeUUID(raw) { return raw, nil } if !looksLikeName(raw) { return "", nil } return s.Store.CameraIDByRef(ctx, clientID, raw) } func (s *Server) resolveVisitor(w http.ResponseWriter, r *http.Request, raw string) (string, bool) { return s.resolve(w, r, raw, s.visitorIDFor, "customer", "That customer no longer exists.") } func (s *Server) resolveSite(w http.ResponseWriter, r *http.Request, raw string) (string, bool) { return s.resolve(w, r, raw, s.siteIDFor, "site", "That shop no longer exists.") } func (s *Server) resolveCamera(w http.ResponseWriter, r *http.Request, raw string) (string, bool) { return s.resolve(w, r, raw, s.cameraIDFor, "camera", "That camera no longer exists.") } // resolveSiteFilter is the query-string form: 400, not 404. // // The distinction is not pedantry. `/api/visits` exists and answered - what was // wrong was the filter the caller attached to it, and a 404 there reads as "the // arrivals feed is gone", which is a very different thing to go and investigate. // A path segment names the resource itself, so an unknown one IS a 404. func (s *Server) resolveSiteFilter(w http.ResponseWriter, r *http.Request, raw string) (string, bool) { p := PrincipalFrom(r.Context()) id, err := s.siteIDFor(r.Context(), p.ClientID, raw) if err != nil { s.serverError(w, "resolve site", err) return "", false } if id == "" { badRequest(w, "no shop called "+strconv.Quote(raw)) return "", false } return id, true } func (s *Server) resolve(w http.ResponseWriter, r *http.Request, raw string, lookup func(context.Context, string, string) (string, error), what, gone string) (string, bool) { p := PrincipalFrom(r.Context()) id, err := lookup(r.Context(), p.ClientID, raw) if err != nil { s.serverError(w, "resolve "+what, err) return "", false } if id == "" { writeErr(w, http.StatusNotFound, "not_found", gone) return "", false } return id, true } // siteParam reads "which shop" off a query string. // // Reports have always taken `site` and the arrivals feed `site_id`, which is a // wart rather than a rule - and an unknown query parameter is silently ignored, // so getting it the wrong way round returns the whole estate instead of an // error. Both names are accepted everywhere now; `site` is the documented one. func siteParam(r *http.Request) string { if v := trim(r.URL.Query().Get("site")); v != "" { return v } return trim(r.URL.Query().Get("site_id")) }