diff --git a/.gitignore b/.gitignore index dce69bc..0c7c14e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ coverage composer.lock vendor .phpunit.cache +.env diff --git a/README.md b/README.md index 82a4823..3877e0c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ You'll need an IPinfo API access token, which you can get by signing up for a fr The free plan is limited to 50,000 requests per month, and doesn't include some of the data fields such as IP type and company data. To enable all the data fields and additional request volumes see [https://ipinfo.io/pricing](https://ipinfo.io/pricing?ref=lib-PHP). -⚠️ Note: This library does not currently support our newest free API https://ipinfo.io/lite. If you’d like to use IPinfo Lite, you can call the [endpoint directly](https://ipinfo.io/developers/lite-api) using your preferred HTTP client. Developers are also welcome to contribute support for Lite by submitting a pull request. +The library also supports the Lite API, see the [Lite API section](#lite-api) for more info. #### Installation @@ -233,6 +233,21 @@ $details->all; */ ``` +### Lite API + +The library gives the possibility to use the [Lite API](https://ipinfo.io/developers/lite-api) too, authentication with your token is still required. + +The returned details are slightly different from the Core API. + +```php +$access_token = '123456789abc'; +$client = new IPinfoLite($access_token); + +$res = $client->getDetails("8.8.8.8") +$res->country_code // US +$res->country // United States +``` + ### Caching In-memory caching of `Details` data is provided by default via the [symfony/cache](https://github.com/symfony/cache/) library. LRU (least recently used) cache-invalidation functionality has been added to the default TTL (time to live). This means that values will be cached for the specified duration; if the cache's max size is reached, cache values will be invalidated as necessary, starting with the oldest cached value. diff --git a/src/DetailsLite.php b/src/DetailsLite.php new file mode 100644 index 0000000..2b9dbf7 --- /dev/null +++ b/src/DetailsLite.php @@ -0,0 +1,43 @@ + $value) { + $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/IPinfoLite.php b/src/IPinfoLite.php new file mode 100644 index 0000000..f51c8df --- /dev/null +++ b/src/IPinfoLite.php @@ -0,0 +1,283 @@ +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 Details 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 = []) + { + $country_code = $details["country_code"] ?? null; + $details["country_name"] = $details["country"] ?? null; + $details["is_eu"] = in_array($country_code, $this->eu_countries); + $details["country_flag"] = + $this->countries_flags[$country_code] ?? null; + $details["country_flag_url"] = + self::COUNTRY_FLAG_URL . $country_code . ".svg"; + $details["country_currency"] = + $this->countries_currencies[$country_code] ?? null; + + return new DetailsLite($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.1.4", + "accept" => "application/json", + "content-type" => "application/json", + ]; + + if ($this->access_token) { + $headers["authorization"] = "Bearer {$this->access_token}"; + } + + return $headers; + } + + /** + * Returns a versioned cache key given a user-input key. + * @param string $k key to transform into a versioned cache key. + * @return string the versioned cache key. + */ + private function cacheKey($k) + { + return sprintf("%s_v%s", $k, self::CACHE_KEY_VSN); + } + + /** + * Check if an IP address is a bogon. + * + * @param string $ip The IP address to check + * @return bool True if the IP address is a bogon, false otherwise + */ + public function isBogon($ip) + { + // Check if the IP address is in the range + return IpUtils::checkIp($ip, $this->bogonNetworks); + } + + // List of bogon CIDRs. + protected $bogonNetworks = [ + "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", + ]; +} diff --git a/tests/IPinfoLiteTest.php b/tests/IPinfoLiteTest.php new file mode 100644 index 0000000..b9ef611 --- /dev/null +++ b/tests/IPinfoLiteTest.php @@ -0,0 +1,199 @@ +assertSame($tok, $client->access_token); + } + + public function testDefaultCountries() + { + $client = new IPinfoLite(); + $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 IPinfoLite($tok, ["cache" => $cache]); + $this->assertSame($cache, $client->cache); + } + + public function testDefaultCacheSettings() + { + $client = new IPinfoLite(); + $this->assertSame(IPinfoLite::CACHE_MAXSIZE, $client->cache->maxsize); + $this->assertSame(IPinfoLite::CACHE_TTL, $client->cache->ttl); + } + + public function testCustomCacheSettings() + { + $tok = "this is a fake access token"; + $settings = ["cache_maxsize" => 100, "cache_ttl" => 11]; + $client = new IPinfoLite($tok, $settings); + $this->assertSame($settings["cache_maxsize"], $client->cache->maxsize); + $this->assertSame($settings["cache_ttl"], $client->cache->ttl); + } + + public function testFormatDetailsObject() + { + $test_details = [ + "country" => "United States", + "country_code" => "US", + ]; + + $h = new IPinfoLite(); + $res = $h->formatDetailsObject($test_details); + + $this->assertEquals("United States", $res->country); + $this->assertEquals("United States", $res->country_name); + $this->assertEquals("US", $res->country_code); + $this->assertEquals("🇺🇸", $res->country_flag["emoji"]); + $this->assertEquals( + "https://cdn.ipinfo.io/static/images/countries-flags/US.svg", + $res->country_flag_url + ); + $this->assertEquals("U+1F1FA U+1F1F8", $res->country_flag["unicode"]); + } + + public function testBadIP() + { + $ip = "fake_ip"; + $h = new IPinfoLite(); + $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 IPinfoLite($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->asn); + $this->assertNotNull($res->as_name); + $this->assertNotNull($res->as_domain); + $this->assertNotNull($res->country_code); + $this->assertNotNull($res->country); + $this->assertNotNull($res->continent_code); + $this->assertNotNull($res->continent); + $this->assertNotNull($res->country_name); + $this->assertNotNull($res->is_eu); + $this->assertNotNull($res->country_flag); + $this->assertNotNull($res->country_flag_url); + $this->assertNotNull($res->country_currency); + // Bogon must not be set + $this->assertNull($res->bogon); + + $res = $h->getDetails("me"); + + // We can't know the actual values, we just need to check they're set + $this->assertNotNull($res->ip); + $this->assertNotNull($res->asn); + $this->assertNotNull($res->as_name); + $this->assertNotNull($res->as_domain); + $this->assertNotNull($res->country_code); + $this->assertNotNull($res->country); + $this->assertNotNull($res->continent_code); + $this->assertNotNull($res->continent); + $this->assertNotNull($res->country_name); + $this->assertNotNull($res->is_eu); + $this->assertNotNull($res->country_flag); + $this->assertNotNull($res->country_flag_url); + $this->assertNotNull($res->country_currency); + // Bogon must not be set + $this->assertNull($res->bogon); + } + + public function testLookup() + { + $tok = getenv("IPINFO_TOKEN"); + if (!$tok) { + $this->markTestSkipped("IPINFO_TOKEN env var required"); + } + + $h = new IPinfoLite($tok); + $ip = "8.8.8.8"; + + // test multiple times for cache hits + for ($i = 0; $i < 5; $i++) { + $res = $h->getDetails($ip); + $this->assertEquals("8.8.8.8", $res->ip); + $this->assertEquals("AS15169", $res->asn); + $this->assertEquals("Google LLC", $res->as_name); + $this->assertEquals("google.com", $res->as_domain); + $this->assertEquals("US", $res->country_code); + $this->assertEquals("United States", $res->country); + $this->assertEquals("NA", $res->continent_code); + $this->assertEquals("North America", $res->continent); + $this->assertEquals("United States", $res->country_name); + $this->assertEquals("", $res->is_eu); + $this->assertEquals("🇺🇸", $res->country_flag["emoji"]); + $this->assertEquals( + "https://cdn.ipinfo.io/static/images/countries-flags/US.svg", + $res->country_flag_url + ); + $this->assertEquals( + "U+1F1FA U+1F1F8", + $res->country_flag["unicode"] + ); + $this->assertEquals("USD", $res->country_currency["code"]); + $this->assertEquals('$', $res->country_currency["symbol"]); + } + } + + public function testGuzzleOverride() + { + $tok = getenv("IPINFO_TOKEN"); + if (!$tok) { + $this->markTestSkipped("IPINFO_TOKEN env var required"); + } + + $h = new IPinfoLite($tok, [ + "guzzle_opts" => [ + "headers" => [ + "authorization" => "Bearer blah", + ], + ], + ]); + $ip = "8.8.8.8"; + + $this->expectException(IPinfoException::class); + $res = $h->getDetails($ip); + } + + public function testBogonLocal4() + { + $h = new IPinfoLite(); + $ip = "127.0.0.1"; + $res = $h->getDetails($ip); + $this->assertEquals($res->ip, "127.0.0.1"); + $this->assertTrue($res->bogon); + } + + public function testBogonLocal6() + { + $h = new IPinfoLite(); + $ip = "2002:7f00::"; + $res = $h->getDetails($ip); + $this->assertEquals($res->ip, "2002:7f00::"); + $this->assertTrue($res->bogon); + } +} diff --git a/tests/IPinfoTest.php b/tests/IPinfoTest.php index fe75694..5d870ec 100644 --- a/tests/IPinfoTest.php +++ b/tests/IPinfoTest.php @@ -98,9 +98,9 @@ public function testLookup() $this->assertEquals($res->country_currency['symbol'], '$'); $this->assertEquals($res->continent['code'], 'NA'); $this->assertEquals($res->continent['name'], 'North America'); - $this->assertEquals($res->loc, '38.0088,-122.1175'); - $this->assertEquals($res->latitude, '38.0088'); - $this->assertEquals($res->longitude, '-122.1175'); + $this->assertEquals($res->loc, '37.4056,-122.0775'); + $this->assertEquals($res->latitude, '37.4056'); + $this->assertEquals($res->longitude, '-122.0775'); $this->assertEquals($res->postal, '94043'); $this->assertEquals($res->timezone, 'America/Los_Angeles'); if ($res->asn !== null) { @@ -205,12 +205,12 @@ public function testGetBatchDetails() $this->assertEquals($res['8.8.8.8/hostname'], 'dns.google'); $ipV4 = $res['4.4.4.4']; $this->assertEquals($ipV4['ip'], '4.4.4.4'); - $this->assertEquals($ipV4['city'], 'Monroe'); - $this->assertEquals($ipV4['region'], 'Louisiana'); - $this->assertEquals($ipV4['country'], 'US'); - $this->assertEquals($ipV4['loc'], '32.5530,-92.0422'); - $this->assertEquals($ipV4['postal'], '71203'); - $this->assertEquals($ipV4['timezone'], 'America/Chicago'); + $this->assertEquals($ipV4['city'], 'Dhaka'); + $this->assertEquals($ipV4['region'], 'Dhaka Division'); + $this->assertEquals($ipV4['country'], 'BD'); + $this->assertEquals($ipV4['loc'], '23.7104,90.4074'); + $this->assertEquals($ipV4['postal'], '1000'); + $this->assertEquals($ipV4['timezone'], 'Asia/Dhaka'); $this->assertEquals($ipV4['org'], 'AS3356 Level 3 Parent, LLC'); } } @@ -225,23 +225,19 @@ public function testNetworkDetails() $h = new IPinfo($tok); $res = $h->getDetails('AS123'); - if ($res['error'] === "Token does not have access to this API") { - $this->markTestSkipped('Token does not have access to this API'); - } - - $this->assertEquals($res['asn'], 'AS123'); - $this->assertEquals($res['name'], 'Air Force Systems Networking'); - $this->assertEquals($res['country'], 'US'); - $this->assertEquals($res['allocated'], '1987-08-24'); - $this->assertEquals($res['registry'], 'arin'); - $this->assertEquals($res['domain'], 'af.mil'); - $this->assertEquals($res['num_ips'], 0); - $this->assertEquals($res['type'], 'inactive'); - $this->assertEquals($res['prefixes'], []); - $this->assertEquals($res['prefixes6'], []); - $this->assertEquals($res['peers'], null); - $this->assertEquals($res['upstreams'], null); - $this->assertEquals($res['downstreams'], null); + $this->assertEquals($res->asn, 'AS123'); + $this->assertEquals($res->name, 'Air Force Systems Networking'); + $this->assertEquals($res->country, 'US'); + $this->assertEquals($res->allocated, '1987-08-24'); + $this->assertEquals($res->registry, 'arin'); + $this->assertEquals($res->domain, 'af.mil'); + $this->assertEquals($res->num_ips, 0); + $this->assertEquals($res->type, 'inactive'); + $this->assertEquals($res->prefixes, []); + $this->assertEquals($res->prefixes6, []); + $this->assertEquals($res->peers, null); + $this->assertEquals($res->upstreams, null); + $this->assertEquals($res->downstreams, null); } public function testBogonLocal4() @@ -298,11 +294,11 @@ public function testIPv6DifferentNotations() $standard_ip = "2607:00:4005:805::200e"; $standard_result = $h->getDetails($standard_ip); $this->assertEquals($standard_result->ip, '2607:00:4005:805::200e'); - $this->assertEquals($standard_result->city, 'Killarney'); - $this->assertEquals($standard_result->region, 'Manitoba'); + $this->assertEquals($standard_result->city, 'Langenburg'); + $this->assertEquals($standard_result->region, 'Saskatchewan'); $this->assertEquals($standard_result->country, 'CA'); - $this->assertEquals($standard_result->loc, '49.1833,-99.6636'); - $this->assertEquals($standard_result->timezone, 'America/Winnipeg'); + $this->assertEquals($standard_result->loc, '50.8500,-101.7176'); + $this->assertEquals($standard_result->timezone, 'America/Regina'); // Various notations of the same IPv6 address $variations = [