| 1 | // `carlos geo <ip>`: what the platform's GeoIP database says about an |
| 2 | // address (geoip spec §11.2). API mode only. Its strings await copy review. |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "flag" |
| 9 | "fmt" |
| 10 | "io" |
| 11 | "log" |
| 12 | "net/http" |
| 13 | "net/url" |
| 14 | "os" |
| 15 | ) |
| 16 | |
| 17 | const geoUsage = `usage: carlos geo <ip> --app <a> [flags] |
| 18 | |
| 19 | What the platform's GeoIP database says about an address: the same |
| 20 | answer the X-Carlos-Geo-* headers carry, for an address that is not the |
| 21 | current request. --json prints the console's answer as it is. |
| 22 | ` |
| 23 | |
| 24 | type apiGeoResponse struct { |
| 25 | Found bool `json:"found"` |
| 26 | Country string `json:"country"` |
| 27 | Subdivision string `json:"subdivision"` |
| 28 | Lat *float64 `json:"lat"` |
| 29 | Lon *float64 `json:"lon"` |
| 30 | Source string `json:"source"` |
| 31 | Attribution string `json:"attribution"` |
| 32 | AttributionURL string `json:"attribution_url"` |
| 33 | } |
| 34 | |
| 35 | func runGeo(args []string) { |
| 36 | fs := flag.NewFlagSet("geo", flag.ExitOnError) |
| 37 | app := appFlag(fs, "app whose credential asks") |
| 38 | asJSON := fs.Bool("json", false, "print the console's answer as JSON") |
| 39 | consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)") |
| 40 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 41 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| 42 | fs.Usage = flagUsage(fs, geoUsage) |
| 43 | fs.Parse(ipLast(args)) |
| 44 | if fs.NArg() != 1 { |
| 45 | fs.Usage() |
| 46 | os.Exit(2) |
| 47 | } |
| 48 | sess := requireAPISession("geo", *consoleFlag) |
| 49 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 50 | app2 := resolvedApp(sess, acct, *app, geoUsage) |
| 51 | path := apiPath(acct, app2, "/geo") + "?ip=" + url.QueryEscape(fs.Arg(0)) |
| 52 | st, body, err := sess.getJSON(context.Background(), path) |
| 53 | if err != nil { |
| 54 | log.Fatalf("geo: %v", err) |
| 55 | } |
| 56 | if st != http.StatusOK { |
| 57 | log.Fatalf("geo: %v", apiErrorFromBody(st, body)) |
| 58 | } |
| 59 | if *asJSON { |
| 60 | stdout.Write(body) |
| 61 | fmt.Fprintln(stdout) |
| 62 | return |
| 63 | } |
| 64 | var res apiGeoResponse |
| 65 | if err := json.Unmarshal(body, &res); err != nil { |
| 66 | log.Fatalf("geo: unreadable response: %v", err) |
| 67 | } |
| 68 | printGeo(stdout, fs.Arg(0), res) |
| 69 | } |
| 70 | |
| 71 | func printGeo(w io.Writer, ip string, res apiGeoResponse) { |
| 72 | if !res.Found { |
| 73 | fmt.Fprintf(w, "%s: no location. the database has no answer for this address\n", ip) |
| 74 | return |
| 75 | } |
| 76 | fmt.Fprintf(w, "%s\n", ip) |
| 77 | if res.Country != "" { |
| 78 | fmt.Fprintf(w, " country %s\n", res.Country) |
| 79 | } |
| 80 | if res.Subdivision != "" { |
| 81 | fmt.Fprintf(w, " subdivision %s\n", res.Subdivision) |
| 82 | } |
| 83 | if res.Lat != nil && res.Lon != nil { |
| 84 | fmt.Fprintf(w, " point %.2f, %.2f (an area, not a position)\n", *res.Lat, *res.Lon) |
| 85 | } |
| 86 | fmt.Fprintf(w, " source %s\n", res.Source) |
| 87 | if res.Attribution != "" { |
| 88 | fmt.Fprintf(w, " credit %s (%s)\n", res.Attribution, res.AttributionURL) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // ipLast moves a leading address after the flags, so both |
| 93 | // `carlos geo 1.2.3.4 --app a` and `carlos geo --app a 1.2.3.4` work |
| 94 | // (Go's flag package stops at the first non-flag). |
| 95 | func ipLast(args []string) []string { |
| 96 | if len(args) > 0 && len(args[0]) > 0 && args[0][0] != '-' { |
| 97 | return append(append([]string(nil), args[1:]...), args[0]) |
| 98 | } |
| 99 | return args |
| 100 | } |
| 101 | |