// Package http provides the HTTP server and routing functionality. package http import ( "encoding/json" "log/slog" "net" "net/http" "strings" "git.eeqj.de/sneak/ipapi/internal/database" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) // IPInfo represents the API response for IP lookups. type IPInfo struct { IP string `json:"ip"` Country string `json:"country,omitempty"` CountryCode string `json:"countryCode,omitempty"` City string `json:"city,omitempty"` Region string `json:"region,omitempty"` PostalCode string `json:"postalCode,omitempty"` Latitude float64 `json:"latitude,omitempty"` Longitude float64 `json:"longitude,omitempty"` Timezone string `json:"timezone,omitempty"` ASN uint `json:"asn,omitempty"` ASNOrg string `json:"asnOrg,omitempty"` } // NewRouter creates a new HTTP router with all endpoints configured. func NewRouter(logger *slog.Logger, db *database.Manager) (chi.Router, error) { r := chi.NewRouter() // Middleware r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Recoverer) const requestTimeout = 60 r.Use(middleware.Timeout(requestTimeout)) // Logging middleware r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := r.Context().Value(middleware.RequestIDKey).(string) logger.Debug("HTTP request", "method", r.Method, "path", r.URL.Path, "remote_addr", r.RemoteAddr, "request_id", start, ) next.ServeHTTP(w, r) }) }) // Health check r.Get("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) if _, err := w.Write([]byte("OK")); err != nil { logger.Error("Failed to write health response", "error", err) } }) // IP lookup endpoint r.Get("/api/{ip}", handleIPLookup(logger, db)) return r, nil } func handleIPLookup(logger *slog.Logger, db *database.Manager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ipStr := chi.URLParam(r, "ip") // Validate IP address ip := net.ParseIP(ipStr) if ip == nil { writeError(w, http.StatusBadRequest, "Invalid IP address") return } info := &IPInfo{ IP: ipStr, } // Look up in Country database if countryDB := db.GetCountryDB(); countryDB != nil { country, err := countryDB.Country(ip) if err == nil { info.Country = country.Country.Names["en"] info.CountryCode = country.Country.IsoCode } } // Look up in City database if cityDB := db.GetCityDB(); cityDB != nil { city, err := cityDB.City(ip) if err == nil { info.City = city.City.Names["en"] if len(city.Subdivisions) > 0 { info.Region = city.Subdivisions[0].Names["en"] } info.PostalCode = city.Postal.Code info.Latitude = city.Location.Latitude info.Longitude = city.Location.Longitude info.Timezone = city.Location.TimeZone } } // Look up in ASN database if asnDB := db.GetASNDB(); asnDB != nil { asn, err := asnDB.ASN(ip) if err == nil { info.ASN = asn.AutonomousSystemNumber info.ASNOrg = asn.AutonomousSystemOrganization } } // Set content type and encode response w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(info); err != nil { logger.Error("Failed to encode response", "error", err) writeError(w, http.StatusInternalServerError, "Internal server error") } } } func writeError(w http.ResponseWriter, code int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) if err := json.NewEncoder(w).Encode(map[string]string{ "error": message, }); err != nil { // Log error but don't try to write again _ = err } } //nolint:unused // will be used in future for rate limiting func getClientIP(r *http.Request) string { // Check X-Forwarded-For header if xff := r.Header.Get("X-Forwarded-For"); xff != "" { ips := strings.Split(xff, ",") if len(ips) > 0 { return strings.TrimSpace(ips[0]) } } // Check X-Real-IP header if xri := r.Header.Get("X-Real-IP"); xri != "" { return xri } // Fall back to RemoteAddr host, _, _ := net.SplitHostPort(r.RemoteAddr) return host }