carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package geoip
2
3import (
4 "math"
5 "net/http"
6 "regexp"
7 "strconv"
8 "strings"
9
10 "github.com/carlosframework/platform/internal/geo"
11)
12
13const (
14 HeaderCountry = "X-Carlos-Geo-Country"
15 HeaderSubdivision = "X-Carlos-Geo-Subdivision"
16 HeaderLat = "X-Carlos-Geo-Lat"
17 HeaderLon = "X-Carlos-Geo-Lon"
18 HeaderSource = "X-Carlos-Geo-Source"
19 // HeaderHop is edge-to-edge only (spec §8.1a): never written to a
20 // request an app receives.
21 HeaderHop = "X-Carlos-Hop-Geo"
22)
23
24var appHeaders = []string{HeaderCountry, HeaderSubdivision, HeaderLat, HeaderLon, HeaderSource}
25
26// State is what the ingress edge did: hit (a reader answered), miss (a
27// reader was loaded and found nothing, or the address is not locatable),
28// off (no lookup was attempted).
29type State string
30
31const (
32 StateHit State = "hit"
33 StateMiss State = "miss"
34 StateOff State = "off"
35)
36
37// Answer is one request's geo result, computed once at ingress or adopted
38// from a trusted transport. Lat/Lon are already rounded to 2 places.
39type Answer struct {
40 State State
41 Country string
42 Subdivision string
43 Source string
44 Lat, Lon float64
45 HasPoint bool
46}
47
48// Off is the answer for a request no lookup was attempted for.
49func Off() Answer { return Answer{State: StateOff} }
50
51func round2(v float64) float64 { return math.Round(v*100) / 100 }
52
53// FromSnapshot turns one reader's answer into a request's answer.
54func FromSnapshot(s geo.Snapshot) Answer {
55 switch {
56 case !s.Loaded:
57 return Off()
58 case !s.Found:
59 return Answer{State: StateMiss, Source: s.Source}
60 }
61 a := Answer{State: StateHit, Country: s.Result.Country, Subdivision: s.Result.Subdivision, Source: s.Source}
62 if s.Result.HasPoint {
63 a.Lat, a.Lon, a.HasPoint = round2(s.Result.Point.Lat), round2(s.Result.Point.Lon), true
64 }
65 return a
66}
67
68// geoPrefix is the reserved namespace: spec §8.1 strips EVERY header in
69// it, not just the five this version writes, so a client cannot hand an app
70// an X-Carlos-Geo-City (or any later name) that looks platform-set.
71const geoPrefix = "X-Carlos-Geo-"
72
73// StripAll deletes every X-Carlos-Geo-* header and X-Carlos-Hop-Geo, in
74// any case and with underscores in place of hyphens: CGI and WSGI turn
75// both into the same HTTP_X_CARLOS_GEO_* key, so X_Carlos_Geo_Country
76// would otherwise reach a Python app as the platform's own header (code
77// review R12-1).
78func StripAll(h http.Header) {
79 for k := range h {
80 if reserved(k) {
81 delete(h, k)
82 }
83 }
84}
85
86// reserved reports whether a header name falls in the platform's geo
87// namespace once underscores are read as hyphens.
88func reserved(k string) bool {
89 n := strings.ReplaceAll(k, "_", "-")
90 return len(n) >= len(geoPrefix) && strings.EqualFold(n[:len(geoPrefix)], geoPrefix) || strings.EqualFold(n, HeaderHop)
91}
92
93// WriteApp deletes the app headers and writes this answer's. A miss and
94// off write nothing at all; Source is written only beside another field.
95func (a Answer) WriteApp(h http.Header) {
96 for _, k := range appHeaders {
97 h.Del(k)
98 }
99 if a.State != StateHit {
100 return
101 }
102 wrote := false
103 if a.Country != "" {
104 h.Set(HeaderCountry, a.Country)
105 wrote = true
106 if a.Subdivision != "" {
107 h.Set(HeaderSubdivision, a.Subdivision)
108 }
109 }
110 if a.HasPoint {
111 h.Set(HeaderLat, strconv.FormatFloat(a.Lat, 'f', 2, 64))
112 h.Set(HeaderLon, strconv.FormatFloat(a.Lon, 'f', 2, 64))
113 wrote = true
114 }
115 if wrote && a.Source != "" {
116 h.Set(HeaderSource, a.Source)
117 }
118}
119
120// HopValue is X-Carlos-Hop-Geo for this answer.
121func (a Answer) HopValue() string {
122 if a.State == StateOff || !sourceRE.MatchString(a.Source) {
123 return string(StateOff)
124 }
125 return string(a.State) + ";src=" + a.Source
126}
127
128// Captured is what a request arrived carrying, before anyone trusts it.
129type Captured struct {
130 Hop, Country, Subdivision, Lat, Lon, Source string
131}
132
133// Capture copies the geo headers out of h and strips them all (spec §8.1
134// step 1). Nothing reads the copy until the transport is authenticated.
135func Capture(h http.Header) Captured {
136 c := Captured{
137 Hop: h.Get(HeaderHop), Country: h.Get(HeaderCountry), Subdivision: h.Get(HeaderSubdivision),
138 Lat: h.Get(HeaderLat), Lon: h.Get(HeaderLon), Source: h.Get(HeaderSource),
139 }
140 StripAll(h)
141 return c
142}
143
144var (
145 countryRE = regexp.MustCompile(`^[A-Z]{2}$`)
146 subdivisionRE = regexp.MustCompile(`^[A-Z]{2}-[A-Z0-9]{1,3}$`)
147 sourceRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
148)
149
150// Adopt validates what a TRUSTED transport carried (spec §8.4). Call it only
151// for a verified hop or a TLS tunnel. Invalid fields are dropped one by one;
152// a hit left with neither a country nor a coordinate pair, or a missing or
153// malformed hop value, is off.
154func (c Captured) Adopt() Answer {
155 state, src, ok := strings.Cut(c.Hop, ";src=")
156 switch {
157 case c.Hop == string(StateOff):
158 return Off()
159 case !ok || !sourceRE.MatchString(src):
160 return Off()
161 case State(state) == StateMiss:
162 return Answer{State: StateMiss, Source: src}
163 case State(state) != StateHit:
164 return Off()
165 }
166 a := Answer{State: StateHit, Source: src}
167 if countryRE.MatchString(c.Country) {
168 a.Country = c.Country
169 if subdivisionRE.MatchString(c.Subdivision) && strings.HasPrefix(c.Subdivision, c.Country+"-") {
170 a.Subdivision = c.Subdivision
171 }
172 }
173 if lat, lon, ok := parsePair(c.Lat, c.Lon); ok {
174 a.Lat, a.Lon, a.HasPoint = lat, lon, true
175 }
176 if a.Country == "" && !a.HasPoint {
177 return Off()
178 }
179 return a
180}
181
182func parsePair(latS, lonS string) (float64, float64, bool) {
183 lat, err1 := strconv.ParseFloat(latS, 64)
184 lon, err2 := strconv.ParseFloat(lonS, 64)
185 if err1 != nil || err2 != nil || math.IsNaN(lat) || math.IsNaN(lon) || math.IsInf(lat, 0) || math.IsInf(lon, 0) ||
186 lat < -90 || lat > 90 || lon < -180 || lon > 180 {
187 return 0, 0, false
188 }
189 return round2(lat), round2(lon), true
190}
191