From edb2a0884c675b35b77dc53b207467427c071e6e Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Wed, 19 Nov 2025 14:56:49 +0100 Subject: [PATCH] Add support for Core bundle --- src/DetailsCore.php | 45 +++++++ src/IPinfoCore.php | 276 +++++++++++++++++++++++++++++++++++++++ tests/IPinfoCoreTest.php | 197 ++++++++++++++++++++++++++++ 3 files changed, 518 insertions(+) create mode 100644 src/DetailsCore.php create mode 100644 src/IPinfoCore.php create mode 100644 tests/IPinfoCoreTest.php diff --git a/src/DetailsCore.php b/src/DetailsCore.php new file mode 100644 index 0000000..fc3d53b --- /dev/null +++ b/src/DetailsCore.php @@ -0,0 +1,45 @@ + $value) { + // Handle nested 'as' object - rename to 'asn' to avoid PHP keyword + if ($property === 'as') { + $this->asn = (object) $value; + } elseif ($property === 'geo') { + $this->geo = (object) $value; + } else { + $this->$property = $value; + } + } + $this->all = $raw_details; + } + + /** + * Returns json string representation. + * + * @internal this class should implement Stringable explicitly when leaving support for PHP verision < 8.0 + */ + public function __toString(): string + { + return json_encode($this); + } +} diff --git a/src/IPinfoCore.php b/src/IPinfoCore.php new file mode 100644 index 0000000..6957069 --- /dev/null +++ b/src/IPinfoCore.php @@ -0,0 +1,276 @@ +access_token = $access_token; + $this->settings = $settings; + + /* + Support a timeout first-class, then a `guzzle_opts` key that can + override anything. + */ + $guzzle_opts = [ + "http_errors" => false, + "headers" => $this->buildHeaders(), + "timeout" => $settings["timeout"] ?? self::REQUEST_TIMEOUT_DEFAULT, + ]; + if (isset($settings["guzzle_opts"])) { + $guzzle_opts = array_merge($guzzle_opts, $settings["guzzle_opts"]); + } + $this->http_client = new Client($guzzle_opts); + + $this->countries = $settings["countries"] ?? self::COUNTRIES_DEFAULT; + $this->countries_flags = + $settings["countries_flags"] ?? self::COUNTRIES_FLAGS_DEFAULT; + $this->countries_currencies = + $settings["countries_currencies"] ?? + self::COUNTRIES_CURRENCIES_DEFAULT; + $this->eu_countries = + $settings["eu_countries"] ?? self::EU_COUNTRIES_DEFAULT; + $this->continents = $settings["continents"] ?? self::CONTINENTS_DEFAULT; + + if ( + !array_key_exists("cache_disabled", $this->settings) || + $this->settings["cache_disabled"] == false + ) { + if (array_key_exists("cache", $settings)) { + $this->cache = $settings["cache"]; + } else { + $maxsize = $settings["cache_maxsize"] ?? self::CACHE_MAXSIZE; + $ttl = $settings["cache_ttl"] ?? self::CACHE_TTL; + $this->cache = new DefaultCache($maxsize, $ttl); + } + } else { + $this->cache = null; + } + } + + /** + * Get formatted details for an IP address. + * @param string|null $ip_address IP address to look up. + * @return DetailsCore Formatted IPinfo data. + * @throws IPinfoException + */ + public function getDetails($ip_address = null) + { + $response_details = $this->getRequestDetails((string) $ip_address); + return $this->formatDetailsObject($response_details); + } + + public function formatDetailsObject($details = []) + { + // Enrich geo object if present + if (isset($details["geo"]) && isset($details["geo"]["country_code"])) { + $country_code = $details["geo"]["country_code"]; + $details["geo"]["country_name"] = $this->countries[$country_code] ?? null; + $details["geo"]["is_eu"] = in_array($country_code, $this->eu_countries); + $details["geo"]["country_flag"] = + $this->countries_flags[$country_code] ?? null; + $details["geo"]["country_flag_url"] = + self::COUNTRY_FLAG_URL . $country_code . ".svg"; + $details["geo"]["country_currency"] = + $this->countries_currencies[$country_code] ?? null; + $details["geo"]["continent_info"] = + $this->continents[$country_code] ?? null; + } + + return new DetailsCore($details); + } + + /** + * Get details for a specific IP address. + * @param string $ip_address IP address to query API for. + * @return array IP response data. + * @throws IPinfoException + */ + public function getRequestDetails(string $ip_address) + { + if ( + // Avoid checking if bogon if the user provided no IP or explicitly + // set it to "me" to get its IP info + $ip_address && + $ip_address != "me" && + $this->isBogon($ip_address) + ) { + return [ + "ip" => $ip_address, + "bogon" => true, + ]; + } + + if ($this->cache != null) { + $cachedRes = $this->cache->get($this->cacheKey($ip_address)); + if ($cachedRes != null) { + return $cachedRes; + } + } + + $url = self::API_URL; + if ($ip_address) { + $url .= "/$ip_address"; + } else { + $url .= "/me"; + } + + try { + $response = $this->http_client->request("GET", $url); + } catch (GuzzleException $e) { + throw new IPinfoException($e->getMessage()); + } catch (Exception $e) { + throw new IPinfoException($e->getMessage()); + } + + if ($response->getStatusCode() == self::STATUS_CODE_QUOTA_EXCEEDED) { + throw new IPinfoException("IPinfo request quota exceeded."); + } elseif ($response->getStatusCode() >= 400) { + throw new IPinfoException( + "Exception: " . + json_encode([ + "status" => $response->getStatusCode(), + "reason" => $response->getReasonPhrase(), + ]) + ); + } + + $raw_details = json_decode($response->getBody(), true); + + if ($this->cache != null) { + $this->cache->set($this->cacheKey($ip_address), $raw_details); + } + + return $raw_details; + } + + /** + * Build headers for API request. + * @return array Headers for API request. + */ + private function buildHeaders() + { + $headers = [ + "user-agent" => "IPinfoClient/PHP/3.2.0", + "accept" => "application/json", + "content-type" => "application/json", + ]; + + if ($this->access_token) { + $headers["authorization"] = "Bearer {$this->access_token}"; + } + + return $headers; + } + + /** + * Check if IP address is bogon. + * @param string $ip_address IP address. + * @return bool true if bogon, else false. + */ + private function isBogon(string $ip_address) + { + return IpUtils::checkIp($ip_address, [ + "0.0.0.0/8", + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.0.0.0/24", + "192.0.2.0/24", + "192.168.0.0/16", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", + "255.255.255.255/32", + "::/128", + "::1/128", + "::ffff:0:0/96", + "::/96", + "100::/64", + "2001:10::/28", + "2001:db8::/32", + "fc00::/7", + "fe80::/10", + "fec0::/10", + "ff00::/8", + "2002::/24", + "2002:a00::/24", + "2002:7f00::/24", + "2002:a9fe::/32", + "2002:ac10::/28", + "2002:c000::/40", + "2002:c000:200::/40", + "2002:c0a8::/32", + "2002:c612::/31", + "2002:c633:6400::/40", + "2002:cb00:7100::/40", + "2002:e000::/20", + "2002:f000::/20", + "2002:ffff:ffff::/48", + "2001::/40", + "2001:0:a00::/40", + "2001:0:7f00::/40", + "2001:0:a9fe::/48", + "2001:0:ac10::/44", + "2001:0:c000::/56", + "2001:0:c000:200::/56", + "2001:0:c0a8::/48", + "2001:0:c612::/47", + "2001:0:c633:6400::/56", + "2001:0:cb00:7100::/56", + "2001:0:e000::/36", + "2001:0:f000::/36", + "2001:0:ffff:ffff::/64", + ]); + } + + /** + * Generate cache key for an IP address. + * @param string $ip IP address. + * @return string Cache key. + */ + private function cacheKey(string $ip) + { + return "core_" . self::CACHE_KEY_VSN . "_" . $ip; + } +} diff --git a/tests/IPinfoCoreTest.php b/tests/IPinfoCoreTest.php new file mode 100644 index 0000000..02ab220 --- /dev/null +++ b/tests/IPinfoCoreTest.php @@ -0,0 +1,197 @@ +assertSame($tok, $client->access_token); + } + + public function testDefaultCountries() + { + $client = new IPinfoCore(); + $this->assertSame("United States", $client->countries["US"]); + $this->assertSame("France", $client->countries["FR"]); + } + + public function testCustomCache() + { + $tok = "this is a fake access token"; + $cache = "this is a fake cache"; + $client = new IPinfoCore($tok, ["cache" => $cache]); + $this->assertSame($cache, $client->cache); + } + + public function testDefaultCacheSettings() + { + $client = new IPinfoCore(); + $this->assertSame(IPinfoCore::CACHE_MAXSIZE, $client->cache->maxsize); + $this->assertSame(IPinfoCore::CACHE_TTL, $client->cache->ttl); + } + + public function testCustomCacheSettings() + { + $tok = "this is a fake access token"; + $settings = ["cache_maxsize" => 100, "cache_ttl" => 11]; + $client = new IPinfoCore($tok, $settings); + $this->assertSame($settings["cache_maxsize"], $client->cache->maxsize); + $this->assertSame($settings["cache_ttl"], $client->cache->ttl); + } + + public function testFormatDetailsObject() + { + $test_details = [ + "ip" => "8.8.8.8", + "geo" => [ + "city" => "Mountain View", + "region" => "California", + "country" => "United States", + "country_code" => "US", + ], + "as" => [ + "asn" => "AS15169", + "name" => "Google LLC", + "domain" => "google.com", + "type" => "hosting", + ], + "is_anycast" => true, + "is_hosting" => true, + ]; + + $h = new IPinfoCore(); + $res = $h->formatDetailsObject($test_details); + + $this->assertEquals("8.8.8.8", $res->ip); + $this->assertEquals("Mountain View", $res->geo->city); + $this->assertEquals("United States", $res->geo->country); + $this->assertEquals("United States", $res->geo->country_name); + $this->assertEquals("US", $res->geo->country_code); + $this->assertEquals("🇺🇸", $res->geo->country_flag["emoji"]); + $this->assertEquals( + "https://cdn.ipinfo.io/static/images/countries-flags/US.svg", + $res->geo->country_flag_url + ); + $this->assertEquals("U+1F1FA U+1F1F8", $res->geo->country_flag["unicode"]); + + $this->assertEquals("AS15169", $res->asn->asn); + $this->assertEquals("Google LLC", $res->asn->name); + $this->assertEquals("google.com", $res->asn->domain); + $this->assertEquals("hosting", $res->asn->type); + + $this->assertTrue($res->is_anycast); + $this->assertTrue($res->is_hosting); + } + + public function testBadIP() + { + $ip = "fake_ip"; + $h = new IPinfoCore(); + $this->expectException(IPinfoException::class); + $h->getDetails($ip); + } + + public function testLookupMe() + { + $tok = getenv("IPINFO_TOKEN"); + if (!$tok) { + $this->markTestSkipped("IPINFO_TOKEN env var required"); + } + + $h = new IPinfoCore($tok); + $res = $h->getDetails(); + + // We can't know the actual values, we just need to check they're set + $this->assertNotNull($res->ip); + $this->assertNotNull($res->geo); + $this->assertNotNull($res->geo->country_code); + $this->assertNotNull($res->geo->country); + $this->assertNotNull($res->geo->country_name); + $this->assertIsBool($res->geo->is_eu); + $this->assertNotNull($res->geo->country_flag); + $this->assertNotNull($res->geo->country_flag_url); + $this->assertNotNull($res->geo->country_currency); + $this->assertNotNull($res->geo->continent_info); + + $this->assertNotNull($res->asn); + $this->assertNotNull($res->asn->asn); + $this->assertNotNull($res->asn->name); + $this->assertNotNull($res->asn->domain); + + $this->assertIsBool($res->is_anonymous); + $this->assertIsBool($res->is_anycast); + $this->assertIsBool($res->is_hosting); + $this->assertIsBool($res->is_mobile); + $this->assertIsBool($res->is_satellite); + } + + public function testLookup() + { + $tok = getenv("IPINFO_TOKEN"); + if (!$tok) { + $this->markTestSkipped("IPINFO_TOKEN env var required"); + } + + $h = new IPinfoCore($tok); + $res = $h->getDetails("8.8.8.8"); + + $this->assertSame("8.8.8.8", $res->ip); + $this->assertEquals("Mountain View", $res->geo->city); + $this->assertEquals("California", $res->geo->region); + $this->assertEquals("CA", $res->geo->region_code); + $this->assertEquals("United States", $res->geo->country); + $this->assertEquals("US", $res->geo->country_code); + $this->assertEquals("North America", $res->geo->continent); + $this->assertEquals("NA", $res->geo->continent_code); + $this->assertIsFloat($res->geo->latitude); + $this->assertIsFloat($res->geo->longitude); + $this->assertEquals("America/Los_Angeles", $res->geo->timezone); + $this->assertEquals("94043", $res->geo->postal_code); + + // Enriched fields + $this->assertEquals("United States", $res->geo->country_name); + $this->assertFalse($res->geo->is_eu); + $this->assertEquals("🇺🇸", $res->geo->country_flag["emoji"]); + $this->assertEquals("U+1F1FA U+1F1F8", $res->geo->country_flag["unicode"]); + $this->assertEquals( + "https://cdn.ipinfo.io/static/images/countries-flags/US.svg", + $res->geo->country_flag_url + ); + $this->assertEquals("USD", $res->geo->country_currency["code"]); + $this->assertEquals("$", $res->geo->country_currency["symbol"]); + $this->assertEquals("NA", $res->geo->continent_info["code"]); + $this->assertEquals("North America", $res->geo->continent_info["name"]); + + $this->assertEquals("AS15169", $res->asn->asn); + $this->assertEquals("Google LLC", $res->asn->name); + $this->assertEquals("google.com", $res->asn->domain); + $this->assertEquals("hosting", $res->asn->type); + + $this->assertFalse($res->is_anonymous); + $this->assertTrue($res->is_anycast); + $this->assertTrue($res->is_hosting); + $this->assertFalse($res->is_mobile); + $this->assertFalse($res->is_satellite); + } + + public function testBogon() + { + $tok = getenv("IPINFO_TOKEN"); + if (!$tok) { + $this->markTestSkipped("IPINFO_TOKEN env var required"); + } + + $h = new IPinfoCore($tok); + $res = $h->getDetails("127.0.0.1"); + + $this->assertEquals("127.0.0.1", $res->ip); + $this->assertTrue($res->bogon); + } +}