feat: social graph features
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// Package geoip resolves a request to an ISO-3166-1 alpha-2 country code,
|
||||
// best-effort. Two sources, in priority order:
|
||||
//
|
||||
// 1. Cloudflare's CF-IPCountry header — instant, no network, present when
|
||||
// the site is proxied through Cloudflare.
|
||||
// 2. A GeoIP lookup on the client IP via the free ip-api.com endpoint —
|
||||
// the fallback for when we're not behind Cloudflare (the current
|
||||
// deployment is DNS-only).
|
||||
//
|
||||
// Everything is fire-and-forget: every failure path returns "" and the
|
||||
// caller simply leaves the country unknown. Nothing here blocks a request
|
||||
// on the critical path — callers run LookupCountryByIP from a goroutine.
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CountryFromCloudflare reads the CF-IPCountry header. Cloudflare sets it
|
||||
// to a 2-letter code, or to a sentinel ("XX" unknown, "T1" Tor) we treat
|
||||
// as absent. Returns "" when the header isn't a usable country.
|
||||
func CountryFromCloudflare(r *http.Request) string {
|
||||
cc := strings.ToUpper(strings.TrimSpace(r.Header.Get("CF-IPCountry")))
|
||||
if len(cc) != 2 || cc == "XX" || cc == "T1" {
|
||||
return ""
|
||||
}
|
||||
return cc
|
||||
}
|
||||
|
||||
// ClientIP returns the best guess at the caller's IP. The chi RealIP
|
||||
// middleware already rewrites r.RemoteAddr from X-Forwarded-For /
|
||||
// X-Real-IP, so we just strip the port.
|
||||
func ClientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
|
||||
if err != nil {
|
||||
// RemoteAddr may already be bare (no port) behind some proxies.
|
||||
return strings.TrimSpace(r.RemoteAddr)
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// LookupCountryByIP resolves an IP to a country code via ip-api.com.
|
||||
// Returns "" for empty / private / loopback IPs (no point asking) and on
|
||||
// any error, timeout, or non-success response. Respects ctx for
|
||||
// cancellation/timeout — callers should pass a short-timeout context.
|
||||
func LookupCountryByIP(ctx context.Context, ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return ""
|
||||
}
|
||||
parsed := net.ParseIP(ip)
|
||||
if parsed == nil || parsed.IsLoopback() || parsed.IsPrivate() ||
|
||||
parsed.IsLinkLocalUnicast() || parsed.IsUnspecified() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Free tier is HTTP-only; countryCode keeps the payload tiny.
|
||||
url := "http://ip-api.com/json/" + ip + "?fields=status,countryCode"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
client := &http.Client{Timeout: 4 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return ""
|
||||
}
|
||||
var body struct {
|
||||
Status string `json:"status"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return ""
|
||||
}
|
||||
if body.Status != "success" {
|
||||
return ""
|
||||
}
|
||||
cc := strings.ToUpper(strings.TrimSpace(body.CountryCode))
|
||||
if len(cc) != 2 {
|
||||
return ""
|
||||
}
|
||||
return cc
|
||||
}
|
||||
Reference in New Issue
Block a user