| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/subtle" |
| 6 | "fmt" |
| 7 | "github.com/carlosframework/platform/internal/geo" |
| 8 | "github.com/carlosframework/platform/internal/geoip" |
| 9 | "net" |
| 10 | "net/http" |
| 11 | "strings" |
| 12 | ) |
| 13 | |
| 14 | // autoRegion is the picker's "let the server guess" value. "@" is outside |
| 15 | // the placement-code grammar, so no real region collides with it (codex |
| 16 | // R2-3). |
| 17 | const autoRegion = "@auto" |
| 18 | |
| 19 | type regionOption struct{ Value, Label string } |
| 20 | |
| 21 | type regionPickerView struct { |
| 22 | Auto string // the @auto option's label (C3) |
| 23 | Options []regionOption |
| 24 | Attribution bool // C24, when the IP step is configured |
| 25 | // AttributionText and AttributionURL are the credit the loaded |
| 26 | // database requires (geoip spec §13): DB-IP's is C24, GeoLite2's is |
| 27 | // MaxMind's. |
| 28 | AttributionText, AttributionURL string |
| 29 | } |
| 30 | |
| 31 | // newAppPicker builds the picker, or nil: switch off, an edges read |
| 32 | // failure, or fewer than two eligible regions (§10). The server's own |
| 33 | // guess is IP, then home; the page script relabels @auto with the |
| 34 | // browser's timezone guess. |
| 35 | func (s *Server) newAppPicker(ctx context.Context, ip net.IP) *regionPickerView { |
| 36 | if !s.appRegionsOn() { |
| 37 | return nil |
| 38 | } |
| 39 | files, err := s.liveEdges(ctx) |
| 40 | if err != nil { |
| 41 | return nil |
| 42 | } |
| 43 | cands := s.regionCands(files) |
| 44 | if eligibleCount(cands) < 2 { |
| 45 | return nil |
| 46 | } |
| 47 | var loc geo.Locator = s.cfg.GeoIP |
| 48 | var rec *recordingLocator |
| 49 | if s.cfg.GeoIPReader != nil { |
| 50 | rec = &recordingLocator{sw: s.cfg.GeoIPReader} |
| 51 | loc = rec |
| 52 | } |
| 53 | g, _ := guessRegion(cands, func(string) bool { return true }, guessInput{IP: ip}, loc) |
| 54 | v := ®ionPickerView{Auto: fmt.Sprintf(regionCopy.AutoOption, s.placeOf(g.Code, cands))} |
| 55 | switch { |
| 56 | case rec != nil: |
| 57 | // The credit names the database that made THIS guess, read under |
| 58 | // the same lock as the lookup (code review R4-4); a guess that used |
| 59 | // no GeoIP data owes no credit. |
| 60 | if a, ok := geoip.AttributionFor(rec.source); ok && rec.used { |
| 61 | v.Attribution, v.AttributionText, v.AttributionURL = true, a.Text, a.URL |
| 62 | } |
| 63 | case s.cfg.GeoIP != nil: |
| 64 | // A bare Locator (tests, older wiring): the IP step is DB-IP's. |
| 65 | v.Attribution, v.AttributionText, v.AttributionURL = true, regionCopy.Attribution, regionCopy.AttributionURL |
| 66 | } |
| 67 | for _, c := range cands { |
| 68 | if c.Eligible { |
| 69 | v.Options = append(v.Options, regionOption{Value: s.homeRegionOut(c.Code), Label: s.placeOf(c.Code, cands)}) |
| 70 | } |
| 71 | } |
| 72 | return v |
| 73 | } |
| 74 | |
| 75 | // pickedRegion turns the form's region field into a regionReq. ok=false |
| 76 | // means the submitted code is not an acceptable region NOW (C5). It is the |
| 77 | // API door's check (validateExplicitRegion), because claimApp does not |
| 78 | // re-validate an explicit region; with the switch off an explicit region is |
| 79 | // neither validated nor refused (spec §9.6, as apicli.go does). |
| 80 | func (s *Server) pickedRegion(ctx context.Context, r *http.Request) (regionReq, bool) { |
| 81 | rq := regionReq{tz: r.FormValue("tz"), ip: s.personIP(r)} |
| 82 | v := strings.TrimSpace(r.FormValue("region")) |
| 83 | if v == "" || v == autoRegion || !s.appRegionsOn() { |
| 84 | return rq, true |
| 85 | } |
| 86 | code, ref := s.validateExplicitRegion(ctx, v) |
| 87 | if ref != nil { |
| 88 | return rq, false |
| 89 | } |
| 90 | rq.explicit, rq.hasExplicit = code, true |
| 91 | return rq, true |
| 92 | } |
| 93 | |
| 94 | // handleNewAppRegion is the page script's guess (spec §9.5): POST with csrf |
| 95 | // and tz, session-gated, writing nothing. The hints are read from the body, |
| 96 | // used for this one guess and neither logged nor echoed (spec §6.5). |
| 97 | func (s *Server) handleNewAppRegion(w http.ResponseWriter, r *http.Request) { |
| 98 | _, raw, _, _, ok := s.account(w, r) |
| 99 | if !ok { |
| 100 | return |
| 101 | } |
| 102 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 103 | jsonError(w, http.StatusForbidden, "Cross-site request blocked.") |
| 104 | return |
| 105 | } |
| 106 | files, err := s.liveEdges(r.Context()) |
| 107 | cands := homeOnlyCandidates() |
| 108 | if err == nil { |
| 109 | cands = s.regionCands(files) |
| 110 | } |
| 111 | g, ok := guessRegion(cands, func(string) bool { return true }, guessInput{TZ: r.FormValue("tz"), IP: s.personIP(r)}, s.cfg.GeoIP) |
| 112 | if !ok { |
| 113 | g = guessResult{"", "default"} |
| 114 | } |
| 115 | writeJSON(w, http.StatusOK, map[string]string{ |
| 116 | "code": s.homeRegionOut(g.Code), "name": s.placeOf(g.Code, cands), "source": g.Source, |
| 117 | }) |
| 118 | } |
| 119 | |
| 120 | // recordingLocator remembers which database answered the guess's lookup. |
| 121 | type recordingLocator struct { |
| 122 | sw *geo.Swappable |
| 123 | source string |
| 124 | used bool |
| 125 | } |
| 126 | |
| 127 | func (r *recordingLocator) Locate(ip net.IP) (geo.Point, bool) { |
| 128 | p, ok, src := r.sw.LocateFrom(ip) |
| 129 | if ok { |
| 130 | r.source, r.used = src, true |
| 131 | } |
| 132 | return p, ok |
| 133 | } |
| 134 | |