diff --git a/README.md b/README.md index d23ad70..34b55c1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Webbhuset GeoIP ## Installation -This module depends on the geoip library by [Sandfox][1] located on GitHub, and of course the Magento hackaton installer. +This module uses the MaxMind GeoLite 2 country database through their API [GeoIP2 PHP API](https://github.com/maxmind/GeoIP2-php) located on GitHub, and of course the Magento hackaton installer. Add the module to your composer file @@ -12,7 +12,7 @@ Add the module to your composer file "repositories": [ { "type": "vcs", - "url": "https://github.com/magento-hackathon/magento-composer-installer" + "url": "https://github.com/Cotya/magento-composer-installer" }, { "type":"vcs", @@ -25,11 +25,22 @@ Then simply update composer by running the update command. composer update ## Getting started -First you need to sync the geoip library by going to **System > Configuration > General > GeoIP Database Downloaded**, and then click the "**Synchronize**" button. - -Then under **System > Configuration > Webbhuset > Geoip > General** you have two settings. The first one is simply to enable geoip redirect. The second one is for locking your country location to the store selected by the geoip module. If that is turned on you will not be able to switch to a different store. You will always be redirected. If it is turned off, you will only be redirected the first time you enter the site (once per session). After that you can switch to whichever store you want. - -### Configure redirect +First you need to sync the geoip library by going to **System > Configuration > Webbhuset > GeoIP > General**, and then click the "**Synchronize**" button which downloads the country database to var/geoip/. + +Then under **System > Configuration > Webbhuset > Geoip > General** you have four settings: + +### Enable Geoip Redirect +Enable/Disable redirect of customer to the first store which the country is allowed in. +If no match is found see Fallback store below. +### Lock user to store +Allows you to lock customers to the store matching their country. With this enabled they will not be able to switch to a different store +and will always be redirected. If it is turned off, you will only be redirected the first time you enter the site (once per session) but is still able to switch store manually. +### Fallback store for not allowed countries +If the customers country does not match any stores allowed countries the customer will be redirected to this store. +### Don't redirect +The controllers/Actions that we don't want to redirect on. + +## Configure redirect The module looks at the *"Default country"* and *"Allowed Countries"* settings under **System > Configuration > General**. You need to set which countries are allowed on each store. Default Country has priority, so if your country is the *Default country* on some store, that store will be selected. If no default country was matched, it looks at *Allowed countries* and selects the first store you are allowed in and redirects to it. [1]: https://github.com/tim-bezhashvyly/Sandfox_GeoIP diff --git a/app/code/community/Webbhuset/Geoip/Block/Adminhtml/Notifications.php b/app/code/community/Webbhuset/Geoip/Block/Adminhtml/Notifications.php new file mode 100644 index 0000000..5f09584 --- /dev/null +++ b/app/code/community/Webbhuset/Geoip/Block/Adminhtml/Notifications.php @@ -0,0 +1,12 @@ +checkFilePermissions(); + } +} diff --git a/app/code/community/Webbhuset/Geoip/Block/System/Config/Info.php b/app/code/community/Webbhuset/Geoip/Block/System/Config/Info.php new file mode 100644 index 0000000..ade30c7 --- /dev/null +++ b/app/code/community/Webbhuset/Geoip/Block/System/Config/Info.php @@ -0,0 +1,46 @@ + + */ +class Webbhuset_Geoip_Block_System_Config_Info + extends Mage_Adminhtml_Block_System_Config_Form_Field +{ + /** + * Remove scope label + * + * @param Varien_Data_Form_Element_Abstract $element + * @return string + */ + public function render(Varien_Data_Form_Element_Abstract $element) + { + $element->unsScope()->unsCanUseWebsiteValue()->unsCanUseDefaultValue(); + + return parent::render($element); + } + + protected function _getElementHtml(Varien_Data_Form_Element_Abstract $element) + { + $ip = Mage::helper('webbhusetgeoip')->getIp(); + $geoIP = Mage::getSingleton('webbhusetgeoip/country'); + $currentCountry = $geoIP->getCountry(); + + if (!$currentCountry) { + $currentCountry = "no match for IP"; + } else { + $country = Mage::getModel('directory/country')->loadByCode($currentCountry); + $countryName = $country->getName(); + $currentCountry = "{$countryName} ({$currentCountry})"; + } + + $html = ""; + $html .=""; + $html .=""; + $html .= "
Country:{$currentCountry}
Current IP:{$ip}
"; + + return $html; + } +} diff --git a/app/code/community/Webbhuset/Geoip/Block/System/Config/Status.php b/app/code/community/Webbhuset/Geoip/Block/System/Config/Status.php new file mode 100644 index 0000000..8b7855f --- /dev/null +++ b/app/code/community/Webbhuset/Geoip/Block/System/Config/Status.php @@ -0,0 +1,37 @@ + + */ +class Webbhuset_Geoip_Block_System_Config_Status + extends Mage_Adminhtml_Block_System_Config_Form_Field +{ + /** + * Remove scope label + * + * @param Varien_Data_Form_Element_Abstract $element + * @return string + */ + public function render(Varien_Data_Form_Element_Abstract $element) + { + $element->unsScope()->unsCanUseWebsiteValue()->unsCanUseDefaultValue(); + + return parent::render($element); + } + + protected function _getElementHtml(Varien_Data_Form_Element_Abstract $element) + { + $database = Mage::getModel('webbhusetgeoip/database'); + if ($date = $database->getDatFileDownloadDate()) { + $format = Mage::app()->getLocale()->getDateTimeFormat(Mage_Core_Model_Locale::FORMAT_TYPE_MEDIUM); + $date = Mage::app()->getLocale()->date(intval($date))->toString($format); + } else { + $date = '-'; + } + + return '
' . $date . '
'; + } +} diff --git a/app/code/community/Webbhuset/Geoip/Block/System/Config/Synchronize.php b/app/code/community/Webbhuset/Geoip/Block/System/Config/Synchronize.php new file mode 100644 index 0000000..06e778f --- /dev/null +++ b/app/code/community/Webbhuset/Geoip/Block/System/Config/Synchronize.php @@ -0,0 +1,115 @@ + + */ +class Webbhuset_Geoip_Block_System_Config_Synchronize extends Mage_Adminhtml_Block_System_Config_Form_Field +{ + /* + * Set template + */ + protected function _construct() + { + parent::_construct(); + $this->setTemplate('webbhuset/geoip/system/config/synchronize.phtml'); + } + + /** + * Remove scope label + * + * @param Varien_Data_Form_Element_Abstract $element + * @return string + */ + public function render(Varien_Data_Form_Element_Abstract $element) + { + $element->unsScope()->unsCanUseWebsiteValue()->unsCanUseDefaultValue(); + + return parent::render($element); + } + + /** + * Return element html + * + * @param Varien_Data_Form_Element_Abstract $element + * @return string + */ + protected function _getElementHtml(Varien_Data_Form_Element_Abstract $element) + { + return $this->_toHtml(); + } + + /** + * Return ajax url for synchronize button + * + * @return string + */ + public function getAjaxSyncUrl() + { + return Mage::getSingleton('adminhtml/url')->getUrl('adminhtml/whgeoip/synchronize'); + } + + /** + * Return ajax url for synchronize button + * + * @return string + */ + public function getAjaxStatusUpdateUrl() + { + return Mage::getSingleton('adminhtml/url')->getUrl('adminhtml/whgeoip/status'); + } + + /** + * Generate synchronize button html + * + * @return string + */ + public function getButtonHtml() + { + /** @var $button Mage_Adminhtml_Block_Widget_Button */ + $button = $this->getLayout()->createBlock('adminhtml/widget_button'); + $button->setData([ + 'id' => 'synchronize_button', + 'label' => $this->helper('adminhtml')->__('Synchronize'), + 'onclick' => 'javascript:synchronize(); return false;' + ]); + + return $button->toHtml(); + } + + /** + * Retrieve last sync params settings + * + * Return array format: + * array ( + * => storage_type int, + * => connection_name string + * ) + * + * @return array + */ + public function getSyncStorageParams() + { + $flag = Mage::getSingleton('core/file_storage')->getSyncFlag(); + $flagData = $flag->getFlagData(); + + if ($flag->getState() == Mage_Core_Model_File_Storage_Flag::STATE_NOTIFIED + && is_array($flagData) + && isset($flagData['destination_storage_type']) && $flagData['destination_storage_type'] != '' + && isset($flagData['destination_connection_name']) + ) { + $storageType = $flagData['destination_storage_type']; + $connectionName = $flagData['destination_connection_name']; + } else { + $storageType = Mage_Core_Model_File_Storage::STORAGE_MEDIA_FILE_SYSTEM; + $connectionName = ''; + } + + return [ + 'storage_type' => $storageType, + 'connection_name' => $connectionName + ]; + } +} diff --git a/app/code/community/Webbhuset/Geoip/Helper/Data.php b/app/code/community/Webbhuset/Geoip/Helper/Data.php index 374cd78..4d90c59 100644 --- a/app/code/community/Webbhuset/Geoip/Helper/Data.php +++ b/app/code/community/Webbhuset/Geoip/Helper/Data.php @@ -31,4 +31,79 @@ public function isCountryAllowed($country, $store) return false; } + /** + * Get size of remote file + * + * @param $file + * @return mixed + */ + public function getSize($file) + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $file); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HEADER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_exec($ch); + + return curl_getinfo($ch, CURLINFO_CONTENT_LENGTH_DOWNLOAD); + } + + /** + * Extracts .mmdb database file from .tar.gz archive + * + * @param $archive + * @param $destination + * @return int + */ + public function extract($archive, $destination, $files) + { + if (!is_array($files)) { + $files = [$files]; + } + + try { + $archive = new PharData($archive); + + foreach (new RecursiveIteratorIterator($archive) as $file) { + if (!in_array($file->getFileName(), $files)) { + continue; + } + + $path = basename($archive->current()->getPathName()); + $fileName = $file->getFileName(); + $fullPath = "{$destination}/{$path}"; + + if ($archive->extractTo($destination, "{$path}/{$fileName}", true)) { + rename("{$fullPath}/$fileName", "{$destination}/{$fileName}"); + rmdir($fullPath); + + return filesize("{$destination}/{$fileName}"); + } + } + + } catch (Exception $e) { + Mage::logException($e); + } + + return 0; + } + + /** + * Returns remote IP or if configured replaces it with configured mock IP + * + * @return string + */ public function getIp() + { + $remoteIp = Mage::helper('core/http')->getRemoteAddr(); + $localIp = Mage::getStoreConfig('webbhusetgeoip/debug/local_ip'); + $devIp = Mage::getStoreConfig('webbhusetgeoip/debug/mock_ip'); + $debug = Mage::getStoreConfig('webbhusetgeoip/debug/enabled'); + + if ($localIp == $remoteIp && $devIp && $debug) { + return $devIp; + } + + return $remoteIp; + } } diff --git a/app/code/community/Webbhuset/Geoip/Model/Abstract.php b/app/code/community/Webbhuset/Geoip/Model/Abstract.php new file mode 100644 index 0000000..f257247 --- /dev/null +++ b/app/code/community/Webbhuset/Geoip/Model/Abstract.php @@ -0,0 +1,15 @@ +localDir = 'geoip'; + $this->localFile = Mage::getBaseDir('var') . '/' . $this->localDir . '/GeoLite2-Country.mmdb'; + + $this->localArchive = Mage::getBaseDir('var') . '/' . $this->localDir . '/GeoLite2-Country.tar.gz'; + $this->remoteArchive = 'http://geolite.maxmind.com/download/geoip/database/GeoLite2-Country.tar.gz'; + } + +} diff --git a/app/code/community/Webbhuset/Geoip/Model/Country.php b/app/code/community/Webbhuset/Geoip/Model/Country.php new file mode 100644 index 0000000..d11e1de --- /dev/null +++ b/app/code/community/Webbhuset/Geoip/Model/Country.php @@ -0,0 +1,143 @@ + + */ +class Webbhuset_Geoip_Model_Country + extends Webbhuset_Geoip_Model_Abstract +{ + protected $country, $reader; + protected $allowedCountries = []; + + public function __construct() + { + parent::__construct(); + + $this->_initReader(); + + if (!$this->record && $this->reader) { + try { + $this->record = $this->reader->country(Mage::helper('webbhusetgeoip')->getIp()); + $allowCountries = explode(',', (string)Mage::getStoreConfig('general/country/allow')); + $this->addAllowedCountry($allowCountries); + } catch (Exception $e) { + Mage::logException($e); + } + } + } + + protected function _initReader() { + try { + $this->reader = new Reader($this->localFile); + } catch (Exception $e) { + Mage::logException($e); + } + } + + /** + * Fetches a new country record from reader by IP and returns the iso code + * + * @param string $ip + * + * @return boolean + */ + public function getCountryByIp($ip) + { + if (!filter_var($ip, FILTER_VALIDATE_IP)) { + return false; + } + + if (!$this->reader) { + return false; + } + + try { + if ($this->record + && $this->record->traits + && $this->record->traits->ipAddress != $ip + ) { + $this->record = $this->reader->country($ip); + } else if (!$this->record) { + $this->record = $this->reader->country($ip); + } + + } catch (Exception $e) { + Mage::logException($e); + return false; + } + + return $this->getCountry(); + } + + /** + * Returns the curent records isoCode + * + * @return string + */ + public function getCountry() + { + if (!$this->record) { + return false; + } + + if (!$this->record->country) { + return false; + } + + return $this->record->country->isoCode; + } + + /** + * Checks if current or supplied country is allowed in current store + * + * @param string $country + * + * @return boolean + */ + public function isCountryAllowed($country = null) + { + if (!$country) { + $country = $this->getCountry(); + } + + if (count($this->allowedCountries) && $country) { + return in_array($country, $this->allowedCountries); + } + + return false; + } + + /** + * Adds a list of countries to the list of allowed countries + * + * @param array $countries + * + * @return boolean + */ + public function addAllowedCountry($countries) + { + if (!is_array($countries)) { + $countries = [$countries]; + } + + $this->allowedCountries = array_merge($this->allowedCountries, $countries); + return $this; + } + + /** + * Adds a list of countries to the list of allowed countries + * + * + * @return array $countries + */ + public function getAllowedCountries() + { + return $this->allowedCountries; + } +} diff --git a/app/code/community/Webbhuset/Geoip/Model/Cron.php b/app/code/community/Webbhuset/Geoip/Model/Cron.php new file mode 100644 index 0000000..e992e4b --- /dev/null +++ b/app/code/community/Webbhuset/Geoip/Model/Cron.php @@ -0,0 +1,11 @@ +update(); + } +} diff --git a/app/code/community/Webbhuset/Geoip/Model/Database.php b/app/code/community/Webbhuset/Geoip/Model/Database.php new file mode 100644 index 0000000..0913f83 --- /dev/null +++ b/app/code/community/Webbhuset/Geoip/Model/Database.php @@ -0,0 +1,91 @@ +localFile) ? filemtime($this->localFile) : 0; + } + + public function getArchivePath() + { + return $this->localArchive; + } + + /** + * Verifies that we have write permission + * + * @return string error messge or empty string + */ + public function checkFilePermissions() + { + /** @var $helper Webbhuset_Geoip_Helper_Data */ + $helper = Mage::helper('webbhusetgeoip'); + + $dir = Mage::getBaseDir('var') . '/' . $this->localDir; + if (file_exists($dir)) { + if (!is_dir($dir)) { + return sprintf($helper->__('%s exists but it is a file, not a directory.'), 'var/' . $this->localDir); + } elseif ((!file_exists($this->localFile) || !file_exists($this->localArchive)) && !is_writable($dir)) { + return sprintf($helper->__('%s exists but is not writable.'), 'var/' . $this->localDir); + } elseif (file_exists($this->localFile) && !is_writable($this->localFile)) { + return sprintf($helper->__('%s is not writable.'), 'var/' . $this->localDir . '/GeoLite2-Country.mmdb'); + } elseif (file_exists($this->localArchive) && !is_writable($this->localArchive)) { + return sprintf($helper->__('%s is not writable.'), 'var/' . $this->localDir . '/GeoLite2-Country.tar.gz'); + } + } elseif (!@mkdir($dir)) { + return sprintf($helper->__('Can\'t create %s directory.'), 'var/' . $this->localDir); + } + + return ''; + } + + /** + * Adds a list of countries to the list of allowed countries + * + * @return string JSON that contains status (error|success) and a message + */ + public function update() + { + /** @var $helper Webbhuset_Geoip_Helper_Data */ + $helper = Mage::helper('webbhusetgeoip'); + + if ($permissions_error = $this->checkFilePermissions()) { + $response['message'] = $permissions_error; + } else { + $remote_file_size = $helper->getSize($this->remoteArchive); + if ($remote_file_size < 100000) { + $response['message'] = $helper->__('You are banned from downloading the file. Please try again in several hours.'); + } else { + /** @var $_session Mage_Core_Model_Session */ + $_session = Mage::getSingleton('core/session'); + $_session->setData('_geoip_file_size', $remote_file_size); + + $src = fopen($this->remoteArchive, 'r'); + $target = fopen($this->localArchive, 'w'); + stream_copy_to_stream($src, $target); + fclose($target); + + $destination = Mage::getBaseDir('var') . '/' . $this->localDir .'/'; + + $response = ['status' => 'error']; + $countryDb = 'GeoLite2-Country.mmdb'; + + if (filesize($this->localArchive)) { + if ($helper->extract($this->localArchive, $destination, $countryDb)) { + $response['status'] = 'success'; + $format = Mage::app()->getLocale()->getDateTimeFormat(Mage_Core_Model_Locale::FORMAT_TYPE_MEDIUM); + $response['date'] = Mage::app()->getLocale()->date(filemtime($this->localFile))->toString($format); + } else { + $response['message'] = $helper->__('UnGzipping failed'); + } + } else { + $response['message'] = $helper->__('Download failed.'); + } + } + } + + echo json_encode($response); + } +} diff --git a/app/code/community/Webbhuset/Geoip/Model/Observer.php b/app/code/community/Webbhuset/Geoip/Model/Observer.php index 7c59723..2a76382 100644 --- a/app/code/community/Webbhuset/Geoip/Model/Observer.php +++ b/app/code/community/Webbhuset/Geoip/Model/Observer.php @@ -8,6 +8,23 @@ */ class Webbhuset_Geoip_Model_Observer { + /** + * Initialize model before front controller + * + * @param Varien_Object $observer + * + * @return void + */ + public function controllerFrontInitBefore(Varien_Event_Observer $observer) + { + $enabled = Mage::getStoreConfigFlag('webbhusetgeoip/general/enabled'); + if (!$enabled) { + return; + } + + Mage::getModel('webbhusetgeoip/country'); + } + /** * Redirect to allowed store with Geoip * @@ -17,8 +34,8 @@ class Webbhuset_Geoip_Model_Observer */ public function redirectStore(Varien_Event_Observer $observer) { - $enabled = Mage::getStoreConfigFlag('geoip/general/enabled'); - $lockStore = Mage::getStoreConfigFlag('geoip/general/lock'); + $enabled = Mage::getStoreConfigFlag('webbhusetgeoip/general/enabled'); + $lockStore = Mage::getStoreConfigFlag('webbhusetgeoip/general/lock'); $exceptions = $this->_getExceptions(); $fallbackStore = $this->_getFallbackStore(); @@ -37,13 +54,13 @@ public function redirectStore(Varien_Event_Observer $observer) $this->checkNoRoute(); - $geoIP = Mage::getSingleton('geoip/country'); + $geoIP = Mage::getSingleton('webbhusetgeoip/country'); $currentCountry = $geoIP->getCountry(); $response = Mage::app()->getResponse(); $session = Mage::getSingleton('core/session'); - $result = new Varien_Object(array('should_proceed' => 1)); + $result = new Varien_Object(['should_proceed' => 1]); - Mage::dispatchEvent('wh_geoip_redirect_store_before', array('result' => $result)); + Mage::dispatchEvent('wh_geoip_redirect_store_before', ['result' => $result]); if ( isset($_SERVER['HTTP_USER_AGENT']) && preg_match('/bot|crawl|slurp|spider/i', $_SERVER['HTTP_USER_AGENT']) @@ -64,8 +81,8 @@ public function redirectStore(Varien_Event_Observer $observer) return; } - $result = new Varien_Object(array('locked_store' => $lockStore)); - Mage::dispatchEvent('wh_geoip_redirect_store_check_locked_before', array('result' => $result)); + $result = new Varien_Object(['locked_store' => $lockStore]); + Mage::dispatchEvent('wh_geoip_redirect_store_check_locked_before', ['result' => $result]); // Only redirect once per session if lock is not enabled if (!$result->getLockedStore() && $session->getIsGeoipRedirected()) { @@ -89,12 +106,12 @@ public function redirectStore(Varien_Event_Observer $observer) } $event = new Varien_Object( - array( - 'store_url' => $store->getCurrentUrl(false), - ) + [ + 'store_url' => $store->getCurrentUrl(false), + ] ); - Mage::dispatchEvent('wh_geoip_redirect_store_set_redirect_before', array('result' => $event)); + Mage::dispatchEvent('wh_geoip_redirect_store_set_redirect_before', ['result' => $event]); $session->setIsGeoipRedirected(true); @@ -113,7 +130,7 @@ public function redirectStore(Varien_Event_Observer $observer) */ public function checkNoRoute() { - $enabled = Mage::getStoreConfigFlag('geoip/general/enabled'); + $enabled = Mage::getStoreConfigFlag('webbhusetgeoip/general/enabled'); if (!$enabled) { return; @@ -147,15 +164,15 @@ public function checkNoRoute() && $controllerName == $request->getControllerName() && $actionName == $request->getActionName() ) { - $geoIP = Mage::getSingleton('geoip/country'); + $geoIP = Mage::getSingleton('webbhusetgeoip/country'); $currentCountry = $geoIP->getCountry(); $response = Mage::app()->getResponse(); + $store = null; if ($currentCountry) { $store = $this->_getStoreForCountry($currentCountry); - } else { - $store = $this->_getFallbackStore(); } + $store = $store ?: $this->_getFallbackStore(); $response->setRedirect($store->getCurrentUrl(false))->sendResponse(); exit; @@ -169,7 +186,7 @@ public function checkNoRoute() */ protected function _getFallbackStore() { - $fallbackStore = Mage::getStoreConfig('geoip/general/fallback'); + $fallbackStore = Mage::getStoreConfig('webbhusetgeoip/general/fallback'); return Mage::app()->getStore($fallbackStore); } @@ -181,13 +198,13 @@ protected function _getFallbackStore() */ protected function _getExceptions() { - $config = Mage::getStoreConfig('geoip/general/exceptions'); + $config = Mage::getStoreConfig('webbhusetgeoip/general/exceptions'); $config = preg_split('/$\R?^/m', $config); - $result = array( - 'frontname' => array(), - 'controller' => array(), - 'action' => array(), - ); + $result = [ + 'frontname' => [], + 'controller' => [], + 'action' => [], + ]; foreach ($config as $exception) { $exception = trim($exception); @@ -204,17 +221,17 @@ protected function _getExceptions() $result['frontname'][] = $exception; break; case 2: - $result['controller'][] = array( + $result['controller'][] = [ 'frontname' => $parts[0], 'controller' => $parts[1], - ); + ]; break; case 3: - $result['action'][] = array( + $result['action'][] = [ 'frontname' => $parts[0], 'controller' => $parts[1], 'action' => $parts[2], - ); + ]; break; default: break; @@ -306,10 +323,10 @@ public function _matchDefaultCountry($country) if (!$store->getIsActive()) { continue; } - $result = new Varien_Object(array('is_allowed' => 1, 'store' => $store)); + $result = new Varien_Object(['is_allowed' => 1, 'store' => $store]); Mage::dispatchEvent( 'wh_geoip_redirect_match_default_country_before', - array('result' => $result) + ['result' => $result] ); $defaultCountry = Mage::getStoreConfig('general/country/default', $store->getId()); @@ -336,10 +353,10 @@ public function _matchAllowedCountry($country) if (!$store->getIsActive()) { continue; } - $result = new Varien_Object(array('is_allowed' => 1, 'store' => $store)); + $result = new Varien_Object(['is_allowed' => 1, 'store' => $store]); Mage::dispatchEvent( 'wh_geoip_redirect_match_allowed_country_before', - array('result' => $result) + ['result' => $result] ); if ($result->getIsAllowed() && Mage::helper('webbhusetgeoip')->isCountryAllowed($country, $store)) { diff --git a/app/code/community/Webbhuset/Geoip/controllers/Adminhtml/WhgeoipController.php b/app/code/community/Webbhuset/Geoip/controllers/Adminhtml/WhgeoipController.php new file mode 100644 index 0000000..98a74b9 --- /dev/null +++ b/app/code/community/Webbhuset/Geoip/controllers/Adminhtml/WhgeoipController.php @@ -0,0 +1,22 @@ +getArchivePath()); + $_totalSize = $session->getData('_geoip_file_size'); + + echo $_totalSize ? $_realSize / $_totalSize * 100 : 0; + } + + public function synchronizeAction() + { + $database = Mage::getModel('webbhusetgeoip/database'); + $database->update(); + } +} diff --git a/app/code/community/Webbhuset/Geoip/etc/adminhtml.xml b/app/code/community/Webbhuset/Geoip/etc/adminhtml.xml index 5f8439f..7cbdf10 100644 --- a/app/code/community/Webbhuset/Geoip/etc/adminhtml.xml +++ b/app/code/community/Webbhuset/Geoip/etc/adminhtml.xml @@ -8,9 +8,9 @@ - + Webbhuset - GeoIP - + diff --git a/app/code/community/Webbhuset/Geoip/etc/config.xml b/app/code/community/Webbhuset/Geoip/etc/config.xml index aeeb5b2..69d28fe 100644 --- a/app/code/community/Webbhuset/Geoip/etc/config.xml +++ b/app/code/community/Webbhuset/Geoip/etc/config.xml @@ -2,7 +2,7 @@ - 0.1.0 + 0.2.0 @@ -45,7 +45,7 @@ - + +api +cms/index/noRoute +whklarnacheckout +checkout +]]> - + + + + + + + Webbhuset_Geoip_Adminhtml + + + + + + + + + + webbhuset/geoip.xml + + + + + + + + 0 0 8 * * + webbhusetgeoip/cron::run + + + diff --git a/app/code/community/Webbhuset/Geoip/etc/system.xml b/app/code/community/Webbhuset/Geoip/etc/system.xml index 98b43b8..c5c3ef8 100644 --- a/app/code/community/Webbhuset/Geoip/etc/system.xml +++ b/app/code/community/Webbhuset/Geoip/etc/system.xml @@ -7,7 +7,7 @@ - + webbhuset 20 @@ -21,10 +21,11 @@ 1 0 0 + 1 - This will enable geoIp redirect to a store with have the customers country in the list of allowed countries (under System > Configuration > General > Allowed Countries). + This will enable geoIp redirect to a store which has the customers country in the list of allowed countries (under System > Configuration > General > Allowed Countries). 10 select adminhtml/system_config_source_yesno @@ -55,7 +56,7 @@ 0 - + Controllers/Actions you dont want to redirect on. Specified one per row, like frontname/controller/action moneybookerspsp\r\npaypal 40 @@ -64,9 +65,76 @@ 0 0 + + + label + webbhusetgeoip/system_config_status + 7000 + 1 + 0 + 0 + + + button + webbhusetgeoip/system_config_synchronize + 7010 + 1 + 0 + 0 + If you synchronize GeoIP database too often you may be banned for several hours. + + + + 1000 + 1 + 0 + 0 + 1 + + + + This will enable you to replace your IP with the configured mock IP. + 10 + select + adminhtml/system_config_source_yesno + 0 + 1 + 1 + 1 + + + + Your IP. + 20 + text + 1 + 1 + 1 + + + + The IP to replace yours with. + 30 + text + 1 + 1 + 1 + + + + text + webbhusetgeoip/system_config_info + 40 + 1 + 1 + 1 + geoip info + + + - + diff --git a/app/design/adminhtml/default/default/layout/webbhuset/Geoip.xml b/app/design/adminhtml/default/default/layout/webbhuset/Geoip.xml new file mode 100644 index 0000000..21b2721 --- /dev/null +++ b/app/design/adminhtml/default/default/layout/webbhuset/Geoip.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/design/adminhtml/default/default/template/webbhuset/geoip/system/config/synchronize.phtml b/app/design/adminhtml/default/default/template/webbhuset/geoip/system/config/synchronize.phtml new file mode 100644 index 0000000..843f216 --- /dev/null +++ b/app/design/adminhtml/default/default/template/webbhuset/geoip/system/config/synchronize.phtml @@ -0,0 +1,69 @@ + + + + +getButtonHtml() ?>Synchronize diff --git a/app/design/adminhtml/default/default/template/webbhuset/geoip/system/notifications.phtml b/app/design/adminhtml/default/default/template/webbhuset/geoip/system/notifications.phtml new file mode 100644 index 0000000..e9c21b8 --- /dev/null +++ b/app/design/adminhtml/default/default/template/webbhuset/geoip/system/notifications.phtml @@ -0,0 +1,3 @@ +checkFilePermissions()) : ?> +
GeoIP:
+ diff --git a/app/etc/modules/Webbhuset_Geoip.xml b/app/etc/modules/Webbhuset_Geoip.xml index be4cddd..bb47117 100644 --- a/app/etc/modules/Webbhuset_Geoip.xml +++ b/app/etc/modules/Webbhuset_Geoip.xml @@ -4,9 +4,6 @@ true community - - - diff --git a/composer.json b/composer.json index 65c1695..2275e5f 100644 --- a/composer.json +++ b/composer.json @@ -9,16 +9,24 @@ "email":"info@webbhuset.com" } ], + "require":{ + "magento-hackathon/magento-composer-installer": "^3.2" + }, "repositories": [ { "type": "vcs", - "url": "https://github.com/webbhuset/Geoip.git" + "url": "https://github.com/maxmind/GeoIP2-php.git" }, { "type": "vcs", - "url": "https://github.com/magento-hackathon/Sandfox_GeoIP.git" + "url": "https://github.com/Cotya/magento-composer-installer.git" } ], + "autoload": { + "psr-0": { + "Webbhuset_Geoip_": "app/code/community/" + } + }, "extra": { "map": [ [ @@ -28,10 +36,23 @@ [ "app/etc/modules/Webbhuset_Geoip.xml", "app/etc/modules/Webbhuset_Geoip.xml" + ], + [ + "app/design/adminhtml/default/default/template/webbhuset/geoip", + "app/design/adminhtml/default/default/template/webbhuset/geoip" + ], + [ + "app/design/adminhtml/default/default/layout/webbhuset/Geoip.xml", + "app/design/adminhtml/default/default/layout/webbhuset/Geoip.xml" + ], + [ + "lib/GeoIp2/", + "lib/GeoIp2/" + ], + [ + "lib/MaxMind/", + "lib/MaxMind/" ] ] - }, - "require": { - "tim-bezhashvyly/geoip": "0.2.*" } } diff --git a/lib/GeoIp2/Database/Reader.php b/lib/GeoIp2/Database/Reader.php new file mode 100644 index 0000000..84cf2be --- /dev/null +++ b/lib/GeoIp2/Database/Reader.php @@ -0,0 +1,283 @@ +dbReader = new DbReader($filename); + $this->locales = $locales; + } + + /** + * This method returns a GeoIP2 City model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + * + * @return \GeoIp2\Model\City + */ + public function city($ipAddress) + { + return $this->modelFor('City', 'City', $ipAddress); + } + + /** + * This method returns a GeoIP2 Country model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + * + * @return \GeoIp2\Model\Country + */ + public function country($ipAddress) + { + return $this->modelFor('Country', 'Country', $ipAddress); + } + + /** + * This method returns a GeoIP2 Anonymous IP model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + * + * @return \GeoIp2\Model\AnonymousIp + */ + public function anonymousIp($ipAddress) + { + return $this->flatModelFor( + 'AnonymousIp', + 'GeoIP2-Anonymous-IP', + $ipAddress + ); + } + + /** + * This method returns a GeoLite2 ASN model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + * + * @return \GeoIp2\Model\Asn + */ + public function asn($ipAddress) + { + return $this->flatModelFor( + 'Asn', + 'GeoLite2-ASN', + $ipAddress + ); + } + + /** + * This method returns a GeoIP2 Connection Type model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + * + * @return \GeoIp2\Model\ConnectionType + */ + public function connectionType($ipAddress) + { + return $this->flatModelFor( + 'ConnectionType', + 'GeoIP2-Connection-Type', + $ipAddress + ); + } + + /** + * This method returns a GeoIP2 Domain model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + * + * @return \GeoIp2\Model\Domain + */ + public function domain($ipAddress) + { + return $this->flatModelFor( + 'Domain', + 'GeoIP2-Domain', + $ipAddress + ); + } + + /** + * This method returns a GeoIP2 Enterprise model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + * + * @return \GeoIp2\Model\Enterprise + */ + public function enterprise($ipAddress) + { + return $this->modelFor('Enterprise', 'Enterprise', $ipAddress); + } + + /** + * This method returns a GeoIP2 ISP model. + * + * @param string $ipAddress an IPv4 or IPv6 address as a string + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address is + * not in the database + * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database + * is corrupt or invalid + * + * @return \GeoIp2\Model\Isp + */ + public function isp($ipAddress) + { + return $this->flatModelFor( + 'Isp', + 'GeoIP2-ISP', + $ipAddress + ); + } + + private function modelFor($class, $type, $ipAddress) + { + $record = $this->getRecord($class, $type, $ipAddress); + + $record['traits']['ip_address'] = $ipAddress; + $class = 'GeoIp2\\Model\\' . $class; + + return new $class($record, $this->locales); + } + + private function flatModelFor($class, $type, $ipAddress) + { + $record = $this->getRecord($class, $type, $ipAddress); + + $record['ip_address'] = $ipAddress; + $class = 'GeoIp2\\Model\\' . $class; + + return new $class($record); + } + + private function getRecord($class, $type, $ipAddress) + { + if (strpos($this->metadata()->databaseType, $type) === false) { + $method = lcfirst($class); + throw new \BadMethodCallException( + "The $method method cannot be used to open a " + . $this->metadata()->databaseType . ' database' + ); + } + $record = $this->dbReader->get($ipAddress); + if ($record === null) { + throw new AddressNotFoundException( + "The address $ipAddress is not in the database." + ); + } + if (!is_array($record)) { + // This can happen on corrupt databases. Generally, + // MaxMind\Db\Reader will throw a + // MaxMind\Db\Reader\InvalidDatabaseException, but occasionally + // the lookup may result in a record that looks valid but is not + // an array. This mostly happens when the user is ignoring all + // exceptions and the more frequent InvalidDatabaseException + // exceptions go unnoticed. + throw new InvalidDatabaseException( + "Expected an array when looking up $ipAddress but received: " + . gettype($record) + ); + } + + return $record; + } + + /** + * @throws \InvalidArgumentException if arguments are passed to the method + * @throws \BadMethodCallException if the database has been closed + * + * @return \MaxMind\Db\Reader\Metadata object for the database + */ + public function metadata() + { + return $this->dbReader->metadata(); + } + + /** + * Closes the GeoIP2 database and returns the resources to the system. + */ + public function close() + { + $this->dbReader->close(); + } +} diff --git a/lib/GeoIp2/Exception/AddressNotFoundException.php b/lib/GeoIp2/Exception/AddressNotFoundException.php new file mode 100644 index 0000000..d548338 --- /dev/null +++ b/lib/GeoIp2/Exception/AddressNotFoundException.php @@ -0,0 +1,10 @@ +uri = $uri; + parent::__construct($message, $httpStatus, $previous); + } +} diff --git a/lib/GeoIp2/Exception/InvalidRequestException.php b/lib/GeoIp2/Exception/InvalidRequestException.php new file mode 100644 index 0000000..6464bcb --- /dev/null +++ b/lib/GeoIp2/Exception/InvalidRequestException.php @@ -0,0 +1,26 @@ +error = $error; + parent::__construct($message, $httpStatus, $uri, $previous); + } +} diff --git a/lib/GeoIp2/Exception/OutOfQueriesException.php b/lib/GeoIp2/Exception/OutOfQueriesException.php new file mode 100644 index 0000000..87a6ade --- /dev/null +++ b/lib/GeoIp2/Exception/OutOfQueriesException.php @@ -0,0 +1,10 @@ +raw = $raw; + } + + /** + * @ignore + * + * @param mixed $field + */ + protected function get($field) + { + if (isset($this->raw[$field])) { + return $this->raw[$field]; + } + if (preg_match('/^is_/', $field)) { + return false; + } + + return null; + } + + /** + * @ignore + * + * @param mixed $attr + */ + public function __get($attr) + { + if ($attr !== 'instance' && property_exists($this, $attr)) { + return $this->$attr; + } + + throw new \RuntimeException("Unknown attribute: $attr"); + } + + /** + * @ignore + * + * @param mixed $attr + */ + public function __isset($attr) + { + return $attr !== 'instance' && isset($this->$attr); + } + + public function jsonSerialize() + { + return $this->raw; + } +} diff --git a/lib/GeoIp2/Model/AnonymousIp.php b/lib/GeoIp2/Model/AnonymousIp.php new file mode 100644 index 0000000..bdaeb89 --- /dev/null +++ b/lib/GeoIp2/Model/AnonymousIp.php @@ -0,0 +1,46 @@ +isAnonymous = $this->get('is_anonymous'); + $this->isAnonymousVpn = $this->get('is_anonymous_vpn'); + $this->isHostingProvider = $this->get('is_hosting_provider'); + $this->isPublicProxy = $this->get('is_public_proxy'); + $this->isTorExitNode = $this->get('is_tor_exit_node'); + $this->ipAddress = $this->get('ip_address'); + } +} diff --git a/lib/GeoIp2/Model/Asn.php b/lib/GeoIp2/Model/Asn.php new file mode 100644 index 0000000..4144142 --- /dev/null +++ b/lib/GeoIp2/Model/Asn.php @@ -0,0 +1,35 @@ +autonomousSystemNumber = $this->get('autonomous_system_number'); + $this->autonomousSystemOrganization = + $this->get('autonomous_system_organization'); + $this->ipAddress = $this->get('ip_address'); + } +} diff --git a/lib/GeoIp2/Model/City.php b/lib/GeoIp2/Model/City.php new file mode 100644 index 0000000..3285903 --- /dev/null +++ b/lib/GeoIp2/Model/City.php @@ -0,0 +1,133 @@ +city = new \GeoIp2\Record\City($this->get('city'), $locales); + $this->location = new \GeoIp2\Record\Location($this->get('location')); + $this->postal = new \GeoIp2\Record\Postal($this->get('postal')); + + $this->createSubdivisions($raw, $locales); + } + + private function createSubdivisions($raw, $locales) + { + if (!isset($raw['subdivisions'])) { + return; + } + + foreach ($raw['subdivisions'] as $sub) { + array_push( + $this->subdivisions, + new \GeoIp2\Record\Subdivision($sub, $locales) + ); + } + } + + /** + * @ignore + * + * @param mixed $attr + */ + public function __get($attr) + { + if ($attr === 'mostSpecificSubdivision') { + return $this->$attr(); + } + + return parent::__get($attr); + } + + /** + * @ignore + * + * @param mixed $attr + */ + public function __isset($attr) + { + if ($attr === 'mostSpecificSubdivision') { + // We always return a mostSpecificSubdivision, even if it is the + // empty subdivision + return true; + } + + return parent::__isset($attr); + } + + private function mostSpecificSubdivision() + { + return empty($this->subdivisions) ? + new \GeoIp2\Record\Subdivision([], $this->locales) : + end($this->subdivisions); + } +} diff --git a/lib/GeoIp2/Model/ConnectionType.php b/lib/GeoIp2/Model/ConnectionType.php new file mode 100644 index 0000000..169e7c1 --- /dev/null +++ b/lib/GeoIp2/Model/ConnectionType.php @@ -0,0 +1,31 @@ +connectionType = $this->get('connection_type'); + $this->ipAddress = $this->get('ip_address'); + } +} diff --git a/lib/GeoIp2/Model/Country.php b/lib/GeoIp2/Model/Country.php new file mode 100644 index 0000000..17833a1 --- /dev/null +++ b/lib/GeoIp2/Model/Country.php @@ -0,0 +1,71 @@ +continent = new \GeoIp2\Record\Continent( + $this->get('continent'), + $locales + ); + $this->country = new \GeoIp2\Record\Country( + $this->get('country'), + $locales + ); + $this->maxmind = new \GeoIp2\Record\MaxMind($this->get('maxmind')); + $this->registeredCountry = new \GeoIp2\Record\Country( + $this->get('registered_country'), + $locales + ); + $this->representedCountry = new \GeoIp2\Record\RepresentedCountry( + $this->get('represented_country'), + $locales + ); + $this->traits = new \GeoIp2\Record\Traits($this->get('traits')); + + $this->locales = $locales; + } +} diff --git a/lib/GeoIp2/Model/Domain.php b/lib/GeoIp2/Model/Domain.php new file mode 100644 index 0000000..f452e86 --- /dev/null +++ b/lib/GeoIp2/Model/Domain.php @@ -0,0 +1,31 @@ +domain = $this->get('domain'); + $this->ipAddress = $this->get('ip_address'); + } +} diff --git a/lib/GeoIp2/Model/Enterprise.php b/lib/GeoIp2/Model/Enterprise.php new file mode 100644 index 0000000..6b98c05 --- /dev/null +++ b/lib/GeoIp2/Model/Enterprise.php @@ -0,0 +1,47 @@ +autonomousSystemNumber = $this->get('autonomous_system_number'); + $this->autonomousSystemOrganization = + $this->get('autonomous_system_organization'); + $this->isp = $this->get('isp'); + $this->organization = $this->get('organization'); + + $this->ipAddress = $this->get('ip_address'); + } +} diff --git a/lib/GeoIp2/ProviderInterface.php b/lib/GeoIp2/ProviderInterface.php new file mode 100644 index 0000000..44851b0 --- /dev/null +++ b/lib/GeoIp2/ProviderInterface.php @@ -0,0 +1,20 @@ +locales = $locales; + parent::__construct($record); + } + + /** + * @ignore + * + * @param mixed $attr + */ + public function __get($attr) + { + if ($attr === 'name') { + return $this->name(); + } + + return parent::__get($attr); + } + + /** + * @ignore + * + * @param mixed $attr + */ + public function __isset($attr) + { + if ($attr === 'name') { + return $this->firstSetNameLocale() === null ? false : true; + } + + return parent::__isset($attr); + } + + private function name() + { + $locale = $this->firstSetNameLocale(); + + return $locale === null ? null : $this->names[$locale]; + } + + private function firstSetNameLocale() + { + foreach ($this->locales as $locale) { + if (isset($this->names[$locale])) { + return $locale; + } + } + + return null; + } +} diff --git a/lib/GeoIp2/Record/AbstractRecord.php b/lib/GeoIp2/Record/AbstractRecord.php new file mode 100644 index 0000000..5d8cb0b --- /dev/null +++ b/lib/GeoIp2/Record/AbstractRecord.php @@ -0,0 +1,61 @@ +record = isset($record) ? $record : []; + } + + /** + * @ignore + * + * @param mixed $attr + */ + public function __get($attr) + { + // XXX - kind of ugly but greatly reduces boilerplate code + $key = $this->attributeToKey($attr); + + if ($this->__isset($attr)) { + return $this->record[$key]; + } elseif ($this->validAttribute($attr)) { + if (preg_match('/^is_/', $key)) { + return false; + } + + return null; + } + throw new \RuntimeException("Unknown attribute: $attr"); + } + + public function __isset($attr) + { + return $this->validAttribute($attr) && + isset($this->record[$this->attributeToKey($attr)]); + } + + private function attributeToKey($attr) + { + return strtolower(preg_replace('/([A-Z])/', '_\1', $attr)); + } + + private function validAttribute($attr) + { + return in_array($attr, $this->validAttributes, true); + } + + public function jsonSerialize() + { + return $this->record; + } +} diff --git a/lib/GeoIp2/Record/City.php b/lib/GeoIp2/Record/City.php new file mode 100644 index 0000000..7f495ad --- /dev/null +++ b/lib/GeoIp2/Record/City.php @@ -0,0 +1,29 @@ +military + * but this could expand to include other types in the future. + */ +class RepresentedCountry extends Country +{ + protected $validAttributes = [ + 'confidence', + 'geonameId', + 'isInEuropeanUnion', + 'isoCode', + 'names', + 'type', + ]; +} diff --git a/lib/GeoIp2/Record/Subdivision.php b/lib/GeoIp2/Record/Subdivision.php new file mode 100644 index 0000000..386c68d --- /dev/null +++ b/lib/GeoIp2/Record/Subdivision.php @@ -0,0 +1,40 @@ +The user type associated with the IP + * address. This can be one of the following values:

+ * + *

+ * This attribute is only available from the Insights web service and the + * GeoIP2 Enterprise database. + *

+ */ +class Traits extends AbstractRecord +{ + /** + * @ignore + */ + protected $validAttributes = [ + 'autonomousSystemNumber', + 'autonomousSystemOrganization', + 'connectionType', + 'domain', + 'ipAddress', + 'isAnonymous', + 'isAnonymousProxy', + 'isAnonymousVpn', + 'isHostingProvider', + 'isLegitimateProxy', + 'isp', + 'isPublicProxy', + 'isSatelliteProvider', + 'isTorExitNode', + 'organization', + 'userType', + ]; +} diff --git a/lib/GeoIp2/WebService/Client.php b/lib/GeoIp2/WebService/Client.php new file mode 100644 index 0000000..1a1ccf1 --- /dev/null +++ b/lib/GeoIp2/WebService/Client.php @@ -0,0 +1,239 @@ +locales = $locales; + + // This is for backwards compatibility. Do not remove except for a + // major version bump. + if (is_string($options)) { + $options = ['host' => $options]; + } + + if (!isset($options['host'])) { + $options['host'] = 'geoip.maxmind.com'; + } + + $options['userAgent'] = $this->userAgent(); + + $this->client = new WsClient($accountId, $licenseKey, $options); + } + + private function userAgent() + { + return 'GeoIP2-API/' . self::VERSION; + } + + /** + * This method calls the GeoIP2 Precision: City service. + * + * @param string $ipAddress IPv4 or IPv6 address as a string. If no + * address is provided, the address that the web service is called + * from will be used. + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address you + * provided is not in our database (e.g., a private address). + * @throws \GeoIp2\Exception\AuthenticationException if there is a problem + * with the account ID or license key that you provided + * @throws \GeoIp2\Exception\OutOfQueriesException if your account is out + * of queries + * @throws \GeoIp2\Exception\InvalidRequestException} if your request was received by the web service but is + * invalid for some other reason. This may indicate an issue + * with this API. Please report the error to MaxMind. + * @throws \GeoIp2\Exception\HttpException if an unexpected HTTP error code or message was returned. + * This could indicate a problem with the connection between + * your server and the web service or that the web service + * returned an invalid document or 500 error code. + * @throws \GeoIp2\Exception\GeoIp2Exception This serves as the parent + * class to the above exceptions. It will be thrown directly + * if a 200 status code is returned but the body is invalid. + * + * @return \GeoIp2\Model\City + */ + public function city($ipAddress = 'me') + { + return $this->responseFor('city', 'City', $ipAddress); + } + + /** + * This method calls the GeoIP2 Precision: Country service. + * + * @param string $ipAddress IPv4 or IPv6 address as a string. If no + * address is provided, the address that the web service is called + * from will be used. + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address you provided is not in our database (e.g., + * a private address). + * @throws \GeoIp2\Exception\AuthenticationException if there is a problem + * with the account ID or license key that you provided + * @throws \GeoIp2\Exception\OutOfQueriesException if your account is out of queries + * @throws \GeoIp2\Exception\InvalidRequestException} if your request was received by the web service but is + * invalid for some other reason. This may indicate an + * issue with this API. Please report the error to MaxMind. + * @throws \GeoIp2\Exception\HttpException if an unexpected HTTP error + * code or message was returned. This could indicate a problem + * with the connection between your server and the web service + * or that the web service returned an invalid document or 500 + * error code. + * @throws \GeoIp2\Exception\GeoIp2Exception This serves as the parent class to the above exceptions. It + * will be thrown directly if a 200 status code is returned but + * the body is invalid. + * + * @return \GeoIp2\Model\Country + */ + public function country($ipAddress = 'me') + { + return $this->responseFor('country', 'Country', $ipAddress); + } + + /** + * This method calls the GeoIP2 Precision: Insights service. + * + * @param string $ipAddress IPv4 or IPv6 address as a string. If no + * address is provided, the address that the web service is called + * from will be used. + * + * @throws \GeoIp2\Exception\AddressNotFoundException if the address you + * provided is not in our database (e.g., a private address). + * @throws \GeoIp2\Exception\AuthenticationException if there is a problem + * with the account ID or license key that you provided + * @throws \GeoIp2\Exception\OutOfQueriesException if your account is out + * of queries + * @throws \GeoIp2\Exception\InvalidRequestException} if your request was received by the web service but is + * invalid for some other reason. This may indicate an + * issue with this API. Please report the error to MaxMind. + * @throws \GeoIp2\Exception\HttpException if an unexpected HTTP error code or message was returned. + * This could indicate a problem with the connection between + * your server and the web service or that the web service + * returned an invalid document or 500 error code. + * @throws \GeoIp2\Exception\GeoIp2Exception This serves as the parent + * class to the above exceptions. It will be thrown directly + * if a 200 status code is returned but the body is invalid. + * + * @return \GeoIp2\Model\Insights + */ + public function insights($ipAddress = 'me') + { + return $this->responseFor('insights', 'Insights', $ipAddress); + } + + private function responseFor($endpoint, $class, $ipAddress) + { + $path = implode('/', [self::$basePath, $endpoint, $ipAddress]); + + try { + $body = $this->client->get('GeoIP2 ' . $class, $path); + } catch (\MaxMind\Exception\IpAddressNotFoundException $ex) { + throw new AddressNotFoundException( + $ex->getMessage(), + $ex->getStatusCode(), + $ex + ); + } catch (\MaxMind\Exception\AuthenticationException $ex) { + throw new AuthenticationException( + $ex->getMessage(), + $ex->getStatusCode(), + $ex + ); + } catch (\MaxMind\Exception\InsufficientFundsException $ex) { + throw new OutOfQueriesException( + $ex->getMessage(), + $ex->getStatusCode(), + $ex + ); + } catch (\MaxMind\Exception\InvalidRequestException $ex) { + throw new InvalidRequestException( + $ex->getMessage(), + $ex->getErrorCode(), + $ex->getStatusCode(), + $ex->getUri(), + $ex + ); + } catch (\MaxMind\Exception\HttpException $ex) { + throw new HttpException( + $ex->getMessage(), + $ex->getStatusCode(), + $ex->getUri(), + $ex + ); + } catch (\MaxMind\Exception\WebServiceException $ex) { + throw new GeoIp2Exception( + $ex->getMessage(), + $ex->getCode(), + $ex + ); + } + + $class = 'GeoIp2\\Model\\' . $class; + + return new $class($body, $this->locales); + } +} diff --git a/lib/MaxMind/Db/Reader.php b/lib/MaxMind/Db/Reader.php new file mode 100644 index 0000000..745f7bb --- /dev/null +++ b/lib/MaxMind/Db/Reader.php @@ -0,0 +1,309 @@ +get method. + */ +class Reader +{ + private static $DATA_SECTION_SEPARATOR_SIZE = 16; + private static $METADATA_START_MARKER = "\xAB\xCD\xEFMaxMind.com"; + private static $METADATA_START_MARKER_LENGTH = 14; + private static $METADATA_MAX_SIZE = 131072; // 128 * 1024 = 128KB + + private $decoder; + private $fileHandle; + private $fileSize; + private $ipV4Start; + private $metadata; + + /** + * Constructs a Reader for the MaxMind DB format. The file passed to it must + * be a valid MaxMind DB file such as a GeoIp2 database file. + * + * @param string $database + * the MaxMind DB file to use + * + * @throws \InvalidArgumentException for invalid database path or unknown arguments + * @throws \MaxMind\Db\Reader\InvalidDatabaseException + * if the database is invalid or there is an error reading + * from it + */ + public function __construct($database) + { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'The constructor takes exactly one argument.' + ); + } + + if (!is_readable($database)) { + throw new \InvalidArgumentException( + "The file \"$database\" does not exist or is not readable." + ); + } + $this->fileHandle = @fopen($database, 'rb'); + if ($this->fileHandle === false) { + throw new \InvalidArgumentException( + "Error opening \"$database\"." + ); + } + $this->fileSize = @filesize($database); + if ($this->fileSize === false) { + throw new \UnexpectedValueException( + "Error determining the size of \"$database\"." + ); + } + + $start = $this->findMetadataStart($database); + $metadataDecoder = new Decoder($this->fileHandle, $start); + list($metadataArray) = $metadataDecoder->decode($start); + $this->metadata = new Metadata($metadataArray); + $this->decoder = new Decoder( + $this->fileHandle, + $this->metadata->searchTreeSize + self::$DATA_SECTION_SEPARATOR_SIZE + ); + } + + /** + * Looks up the address in the MaxMind DB. + * + * @param string $ipAddress + * the IP address to look up + * + * @throws \BadMethodCallException if this method is called on a closed database + * @throws \InvalidArgumentException if something other than a single IP address is passed to the method + * @throws InvalidDatabaseException + * if the database is invalid or there is an error reading + * from it + * + * @return array the record for the IP address + */ + public function get($ipAddress) + { + if (\func_num_args() !== 1) { + throw new \InvalidArgumentException( + 'Method takes exactly one argument.' + ); + } + + if (!\is_resource($this->fileHandle)) { + throw new \BadMethodCallException( + 'Attempt to read from a closed MaxMind DB.' + ); + } + + if (!filter_var($ipAddress, FILTER_VALIDATE_IP)) { + throw new \InvalidArgumentException( + "The value \"$ipAddress\" is not a valid IP address." + ); + } + + if ($this->metadata->ipVersion === 4 && strrpos($ipAddress, ':')) { + throw new \InvalidArgumentException( + "Error looking up $ipAddress. You attempted to look up an" + . ' IPv6 address in an IPv4-only database.' + ); + } + $pointer = $this->findAddressInTree($ipAddress); + if ($pointer === 0) { + return null; + } + + return $this->resolveDataPointer($pointer); + } + + private function findAddressInTree($ipAddress) + { + // XXX - could simplify. Done as a byte array to ease porting + $rawAddress = array_merge(unpack('C*', inet_pton($ipAddress))); + + $bitCount = \count($rawAddress) * 8; + + // The first node of the tree is always node 0, at the beginning of the + // value + $node = $this->startNode($bitCount); + + for ($i = 0; $i < $bitCount; ++$i) { + if ($node >= $this->metadata->nodeCount) { + break; + } + $tempBit = 0xFF & $rawAddress[$i >> 3]; + $bit = 1 & ($tempBit >> 7 - ($i % 8)); + + $node = $this->readNode($node, $bit); + } + if ($node === $this->metadata->nodeCount) { + // Record is empty + return 0; + } elseif ($node > $this->metadata->nodeCount) { + // Record is a data pointer + return $node; + } + throw new InvalidDatabaseException('Something bad happened'); + } + + private function startNode($length) + { + // Check if we are looking up an IPv4 address in an IPv6 tree. If this + // is the case, we can skip over the first 96 nodes. + if ($this->metadata->ipVersion === 6 && $length === 32) { + return $this->ipV4StartNode(); + } + // The first node of the tree is always node 0, at the beginning of the + // value + return 0; + } + + private function ipV4StartNode() + { + // This is a defensive check. There is no reason to call this when you + // have an IPv4 tree. + if ($this->metadata->ipVersion === 4) { + return 0; + } + + if ($this->ipV4Start) { + return $this->ipV4Start; + } + $node = 0; + + for ($i = 0; $i < 96 && $node < $this->metadata->nodeCount; ++$i) { + $node = $this->readNode($node, 0); + } + $this->ipV4Start = $node; + + return $node; + } + + private function readNode($nodeNumber, $index) + { + $baseOffset = $nodeNumber * $this->metadata->nodeByteSize; + + // XXX - probably could condense this. + switch ($this->metadata->recordSize) { + case 24: + $bytes = Util::read($this->fileHandle, $baseOffset + $index * 3, 3); + list(, $node) = unpack('N', "\x00" . $bytes); + + return $node; + case 28: + $middleByte = Util::read($this->fileHandle, $baseOffset + 3, 1); + list(, $middle) = unpack('C', $middleByte); + if ($index === 0) { + $middle = (0xF0 & $middle) >> 4; + } else { + $middle = 0x0F & $middle; + } + $bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 3); + list(, $node) = unpack('N', \chr($middle) . $bytes); + + return $node; + case 32: + $bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 4); + list(, $node) = unpack('N', $bytes); + + return $node; + default: + throw new InvalidDatabaseException( + 'Unknown record size: ' + . $this->metadata->recordSize + ); + } + } + + private function resolveDataPointer($pointer) + { + $resolved = $pointer - $this->metadata->nodeCount + + $this->metadata->searchTreeSize; + if ($resolved > $this->fileSize) { + throw new InvalidDatabaseException( + "The MaxMind DB file's search tree is corrupt" + ); + } + + list($data) = $this->decoder->decode($resolved); + + return $data; + } + + /* + * This is an extremely naive but reasonably readable implementation. There + * are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever + * an issue, but I suspect it won't be. + */ + private function findMetadataStart($filename) + { + $handle = $this->fileHandle; + $fstat = fstat($handle); + $fileSize = $fstat['size']; + $marker = self::$METADATA_START_MARKER; + $markerLength = self::$METADATA_START_MARKER_LENGTH; + $metadataMaxLengthExcludingMarker + = min(self::$METADATA_MAX_SIZE, $fileSize) - $markerLength; + + for ($i = 0; $i <= $metadataMaxLengthExcludingMarker; ++$i) { + for ($j = 0; $j < $markerLength; ++$j) { + fseek($handle, $fileSize - $i - $j - 1); + $matchBit = fgetc($handle); + if ($matchBit !== $marker[$markerLength - $j - 1]) { + continue 2; + } + } + + return $fileSize - $i; + } + throw new InvalidDatabaseException( + "Error opening database file ($filename). " . + 'Is this a valid MaxMind DB file?' + ); + } + + /** + * @throws \InvalidArgumentException if arguments are passed to the method + * @throws \BadMethodCallException if the database has been closed + * + * @return Metadata object for the database + */ + public function metadata() + { + if (\func_num_args()) { + throw new \InvalidArgumentException( + 'Method takes no arguments.' + ); + } + + // Not technically required, but this makes it consistent with + // C extension and it allows us to change our implementation later. + if (!\is_resource($this->fileHandle)) { + throw new \BadMethodCallException( + 'Attempt to read from a closed MaxMind DB.' + ); + } + + return $this->metadata; + } + + /** + * Closes the MaxMind DB and returns resources to the system. + * + * @throws \Exception + * if an I/O error occurs + */ + public function close() + { + if (!\is_resource($this->fileHandle)) { + throw new \BadMethodCallException( + 'Attempt to close a closed MaxMind DB.' + ); + } + fclose($this->fileHandle); + } +} diff --git a/lib/MaxMind/Db/Reader/Decoder.php b/lib/MaxMind/Db/Reader/Decoder.php new file mode 100644 index 0000000..a71b3de --- /dev/null +++ b/lib/MaxMind/Db/Reader/Decoder.php @@ -0,0 +1,341 @@ +fileStream = $fileStream; + $this->pointerBase = $pointerBase; + + $this->pointerBaseByteSize = $pointerBase > 0 ? log($pointerBase, 2) / 8 : 0; + $this->pointerTestHack = $pointerTestHack; + + $this->switchByteOrder = $this->isPlatformLittleEndian(); + } + + public function decode($offset) + { + list(, $ctrlByte) = unpack( + 'C', + Util::read($this->fileStream, $offset, 1) + ); + ++$offset; + + $type = $ctrlByte >> 5; + + // Pointers are a special case, we don't read the next $size bytes, we + // use the size to determine the length of the pointer and then follow + // it. + if ($type === self::_POINTER) { + list($pointer, $offset) = $this->decodePointer($ctrlByte, $offset); + + // for unit testing + if ($this->pointerTestHack) { + return [$pointer]; + } + + list($result) = $this->decode($pointer); + + return [$result, $offset]; + } + + if ($type === self::_EXTENDED) { + list(, $nextByte) = unpack( + 'C', + Util::read($this->fileStream, $offset, 1) + ); + + $type = $nextByte + 7; + + if ($type < 8) { + throw new InvalidDatabaseException( + 'Something went horribly wrong in the decoder. An extended type ' + . 'resolved to a type number < 8 (' + . $type + . ')' + ); + } + + ++$offset; + } + + list($size, $offset) = $this->sizeFromCtrlByte($ctrlByte, $offset); + + return $this->decodeByType($type, $offset, $size); + } + + private function decodeByType($type, $offset, $size) + { + switch ($type) { + case self::_MAP: + return $this->decodeMap($size, $offset); + case self::_ARRAY: + return $this->decodeArray($size, $offset); + case self::_BOOLEAN: + return [$this->decodeBoolean($size), $offset]; + } + + $newOffset = $offset + $size; + $bytes = Util::read($this->fileStream, $offset, $size); + switch ($type) { + case self::_BYTES: + case self::_UTF8_STRING: + return [$bytes, $newOffset]; + case self::_DOUBLE: + $this->verifySize(8, $size); + + return [$this->decodeDouble($bytes), $newOffset]; + case self::_FLOAT: + $this->verifySize(4, $size); + + return [$this->decodeFloat($bytes), $newOffset]; + case self::_INT32: + return [$this->decodeInt32($bytes, $size), $newOffset]; + case self::_UINT16: + case self::_UINT32: + case self::_UINT64: + case self::_UINT128: + return [$this->decodeUint($bytes, $size), $newOffset]; + default: + throw new InvalidDatabaseException( + 'Unknown or unexpected type: ' . $type + ); + } + } + + private function verifySize($expected, $actual) + { + if ($expected !== $actual) { + throw new InvalidDatabaseException( + "The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)" + ); + } + } + + private function decodeArray($size, $offset) + { + $array = []; + + for ($i = 0; $i < $size; ++$i) { + list($value, $offset) = $this->decode($offset); + array_push($array, $value); + } + + return [$array, $offset]; + } + + private function decodeBoolean($size) + { + return $size === 0 ? false : true; + } + + private function decodeDouble($bits) + { + // This assumes IEEE 754 doubles, but most (all?) modern platforms + // use them. + // + // We are not using the "E" format as that was only added in + // 7.0.15 and 7.1.1. As such, we must switch byte order on + // little endian machines. + list(, $double) = unpack('d', $this->maybeSwitchByteOrder($bits)); + + return $double; + } + + private function decodeFloat($bits) + { + // This assumes IEEE 754 floats, but most (all?) modern platforms + // use them. + // + // We are not using the "G" format as that was only added in + // 7.0.15 and 7.1.1. As such, we must switch byte order on + // little endian machines. + list(, $float) = unpack('f', $this->maybeSwitchByteOrder($bits)); + + return $float; + } + + private function decodeInt32($bytes, $size) + { + switch ($size) { + case 0: + return 0; + case 1: + case 2: + case 3: + $bytes = str_pad($bytes, 4, "\x00", STR_PAD_LEFT); + break; + case 4: + break; + default: + throw new InvalidDatabaseException( + "The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)" + ); + } + + list(, $int) = unpack('l', $this->maybeSwitchByteOrder($bytes)); + + return $int; + } + + private function decodeMap($size, $offset) + { + $map = []; + + for ($i = 0; $i < $size; ++$i) { + list($key, $offset) = $this->decode($offset); + list($value, $offset) = $this->decode($offset); + $map[$key] = $value; + } + + return [$map, $offset]; + } + + private function decodePointer($ctrlByte, $offset) + { + $pointerSize = (($ctrlByte >> 3) & 0x3) + 1; + + $buffer = Util::read($this->fileStream, $offset, $pointerSize); + $offset = $offset + $pointerSize; + + switch ($pointerSize) { + case 1: + $packed = (pack('C', $ctrlByte & 0x7)) . $buffer; + list(, $pointer) = unpack('n', $packed); + $pointer += $this->pointerBase; + break; + case 2: + $packed = "\x00" . (pack('C', $ctrlByte & 0x7)) . $buffer; + list(, $pointer) = unpack('N', $packed); + $pointer += $this->pointerBase + 2048; + break; + case 3: + $packed = (pack('C', $ctrlByte & 0x7)) . $buffer; + + // It is safe to use 'N' here, even on 32 bit machines as the + // first bit is 0. + list(, $pointer) = unpack('N', $packed); + $pointer += $this->pointerBase + 526336; + break; + case 4: + // We cannot use unpack here as we might overflow on 32 bit + // machines + $pointerOffset = $this->decodeUint($buffer, $pointerSize); + + $byteLength = $pointerSize + $this->pointerBaseByteSize; + + if ($byteLength <= _MM_MAX_INT_BYTES) { + $pointer = $pointerOffset + $this->pointerBase; + } elseif (\extension_loaded('gmp')) { + $pointer = gmp_strval(gmp_add($pointerOffset, $this->pointerBase)); + } elseif (\extension_loaded('bcmath')) { + $pointer = bcadd($pointerOffset, $this->pointerBase); + } else { + throw new \RuntimeException( + 'The gmp or bcmath extension must be installed to read this database.' + ); + } + } + + return [$pointer, $offset]; + } + + private function decodeUint($bytes, $byteLength) + { + if ($byteLength === 0) { + return 0; + } + + $integer = 0; + + for ($i = 0; $i < $byteLength; ++$i) { + $part = \ord($bytes[$i]); + + // We only use gmp or bcmath if the final value is too big + if ($byteLength <= _MM_MAX_INT_BYTES) { + $integer = ($integer << 8) + $part; + } elseif (\extension_loaded('gmp')) { + $integer = gmp_strval(gmp_add(gmp_mul($integer, 256), $part)); + } elseif (\extension_loaded('bcmath')) { + $integer = bcadd(bcmul($integer, 256), $part); + } else { + throw new \RuntimeException( + 'The gmp or bcmath extension must be installed to read this database.' + ); + } + } + + return $integer; + } + + private function sizeFromCtrlByte($ctrlByte, $offset) + { + $size = $ctrlByte & 0x1f; + + if ($size < 29) { + return [$size, $offset]; + } + + $bytesToRead = $size - 28; + $bytes = Util::read($this->fileStream, $offset, $bytesToRead); + + if ($size === 29) { + $size = 29 + \ord($bytes); + } elseif ($size === 30) { + list(, $adjust) = unpack('n', $bytes); + $size = 285 + $adjust; + } elseif ($size > 30) { + list(, $adjust) = unpack('N', "\x00" . $bytes); + $size = ($adjust & (0x0FFFFFFF >> (32 - (8 * $bytesToRead)))) + + 65821; + } + + return [$size, $offset + $bytesToRead]; + } + + private function maybeSwitchByteOrder($bytes) + { + return $this->switchByteOrder ? strrev($bytes) : $bytes; + } + + private function isPlatformLittleEndian() + { + $testint = 0x00FF; + $packed = pack('S', $testint); + + return $testint === current(unpack('v', $packed)); + } +} diff --git a/lib/MaxMind/Db/Reader/InvalidDatabaseException.php b/lib/MaxMind/Db/Reader/InvalidDatabaseException.php new file mode 100644 index 0000000..d2a9a77 --- /dev/null +++ b/lib/MaxMind/Db/Reader/InvalidDatabaseException.php @@ -0,0 +1,10 @@ +binaryFormatMajorVersion = + $metadata['binary_format_major_version']; + $this->binaryFormatMinorVersion = + $metadata['binary_format_minor_version']; + $this->buildEpoch = $metadata['build_epoch']; + $this->databaseType = $metadata['database_type']; + $this->languages = $metadata['languages']; + $this->description = $metadata['description']; + $this->ipVersion = $metadata['ip_version']; + $this->nodeCount = $metadata['node_count']; + $this->recordSize = $metadata['record_size']; + $this->nodeByteSize = $this->recordSize / 4; + $this->searchTreeSize = $this->nodeCount * $this->nodeByteSize; + } + + public function __get($var) + { + return $this->$var; + } +} diff --git a/lib/MaxMind/Db/Reader/Util.php b/lib/MaxMind/Db/Reader/Util.php new file mode 100644 index 0000000..87ebbf1 --- /dev/null +++ b/lib/MaxMind/Db/Reader/Util.php @@ -0,0 +1,26 @@ +uri = $uri; + parent::__construct($message, $httpStatus, $previous); + } + + public function getUri() + { + return $this->uri; + } + + public function getStatusCode() + { + return $this->getCode(); + } +} diff --git a/lib/MaxMind/Exception/InsufficientFundsException.php b/lib/MaxMind/Exception/InsufficientFundsException.php new file mode 100644 index 0000000..fe159a2 --- /dev/null +++ b/lib/MaxMind/Exception/InsufficientFundsException.php @@ -0,0 +1,10 @@ +error = $error; + parent::__construct($message, $httpStatus, $uri, $previous); + } + + public function getErrorCode() + { + return $this->error; + } +} diff --git a/lib/MaxMind/Exception/IpAddressNotFoundException.php b/lib/MaxMind/Exception/IpAddressNotFoundException.php new file mode 100644 index 0000000..31608f7 --- /dev/null +++ b/lib/MaxMind/Exception/IpAddressNotFoundException.php @@ -0,0 +1,7 @@ +accountId = $accountId; + $this->licenseKey = $licenseKey; + + $this->httpRequestFactory = isset($options['httpRequestFactory']) + ? $options['httpRequestFactory'] + : new RequestFactory(); + + if (isset($options['host'])) { + $this->host = $options['host']; + } + if (isset($options['userAgent'])) { + $this->userAgentPrefix = $options['userAgent'] . ' '; + } + + $this->caBundle = isset($options['caBundle']) ? + $this->caBundle = $options['caBundle'] : $this->getCaBundle(); + + if (isset($options['connectTimeout'])) { + $this->connectTimeout = $options['connectTimeout']; + } + if (isset($options['timeout'])) { + $this->timeout = $options['timeout']; + } + + if (isset($options['proxy'])) { + $this->proxy = $options['proxy']; + } + } + + /** + * @param string $service name of the service querying + * @param string $path the URI path to use + * @param array $input the data to be posted as JSON + * + * @throws InvalidInputException when the request has missing or invalid + * data + * @throws AuthenticationException when there is an issue authenticating the + * request + * @throws InsufficientFundsException when your account is out of funds + * @throws InvalidRequestException when the request is invalid for some + * other reason, e.g., invalid JSON in the POST. + * @throws HttpException when an unexpected HTTP error occurs + * @throws WebServiceException when some other error occurs. This also + * serves as the base class for the above exceptions. + * + * @return array The decoded content of a successful response + */ + public function post($service, $path, $input) + { + $body = json_encode($input); + if ($body === false) { + throw new InvalidInputException( + 'Error encoding input as JSON: ' + . $this->jsonErrorDescription() + ); + } + + $request = $this->createRequest( + $path, + ['Content-Type: application/json'] + ); + + list($statusCode, $contentType, $body) = $request->post($body); + + return $this->handleResponse( + $statusCode, + $contentType, + $body, + $service, + $path + ); + } + + public function get($service, $path) + { + $request = $this->createRequest($path); + + list($statusCode, $contentType, $body) = $request->get(); + + return $this->handleResponse( + $statusCode, + $contentType, + $body, + $service, + $path + ); + } + + private function userAgent() + { + $curlVersion = curl_version(); + + return $this->userAgentPrefix . 'MaxMind-WS-API/' . self::VERSION . ' PHP/' . PHP_VERSION . + ' curl/' . $curlVersion['version']; + } + + private function createRequest($path, $headers = []) + { + array_push( + $headers, + 'Authorization: Basic ' + . base64_encode($this->accountId . ':' . $this->licenseKey), + 'Accept: application/json' + ); + + return $this->httpRequestFactory->request( + $this->urlFor($path), + [ + 'caBundle' => $this->caBundle, + 'connectTimeout' => $this->connectTimeout, + 'headers' => $headers, + 'proxy' => $this->proxy, + 'timeout' => $this->timeout, + 'userAgent' => $this->userAgent(), + ] + ); + } + + /** + * @param int $statusCode the HTTP status code of the response + * @param string $contentType the Content-Type of the response + * @param string $body the response body + * @param string $service the name of the service + * @param string $path the path used in the request + * + * @throws AuthenticationException when there is an issue authenticating the + * request + * @throws InsufficientFundsException when your account is out of funds + * @throws InvalidRequestException when the request is invalid for some + * other reason, e.g., invalid JSON in the POST. + * @throws HttpException when an unexpected HTTP error occurs + * @throws WebServiceException when some other error occurs. This also + * serves as the base class for the above exceptions + * + * @return array The decoded content of a successful response + */ + private function handleResponse( + $statusCode, + $contentType, + $body, + $service, + $path + ) { + if ($statusCode >= 400 && $statusCode <= 499) { + $this->handle4xx($statusCode, $contentType, $body, $service, $path); + } elseif ($statusCode >= 500) { + $this->handle5xx($statusCode, $service, $path); + } elseif ($statusCode !== 200) { + $this->handleUnexpectedStatus($statusCode, $service, $path); + } + + return $this->handleSuccess($body, $service); + } + + /** + * @return string describing the JSON error + */ + private function jsonErrorDescription() + { + $errno = json_last_error(); + switch ($errno) { + case JSON_ERROR_DEPTH: + return 'The maximum stack depth has been exceeded.'; + case JSON_ERROR_STATE_MISMATCH: + return 'Invalid or malformed JSON.'; + case JSON_ERROR_CTRL_CHAR: + return 'Control character error.'; + case JSON_ERROR_SYNTAX: + return 'Syntax error.'; + case JSON_ERROR_UTF8: + return 'Malformed UTF-8 characters.'; + default: + return "Other JSON error ($errno)."; + } + } + + /** + * @param string $path the path to use in the URL + * + * @return string the constructed URL + */ + private function urlFor($path) + { + return 'https://' . $this->host . $path; + } + + /** + * @param int $statusCode the HTTP status code + * @param string $contentType the response content-type + * @param string $body the response body + * @param string $service the service name + * @param string $path the path used in the request + * + * @throws AuthenticationException + * @throws HttpException + * @throws InsufficientFundsException + * @throws InvalidRequestException + */ + private function handle4xx( + $statusCode, + $contentType, + $body, + $service, + $path + ) { + if (strlen($body) === 0) { + throw new HttpException( + "Received a $statusCode error for $service with no body", + $statusCode, + $this->urlFor($path) + ); + } + if (!strstr($contentType, 'json')) { + throw new HttpException( + "Received a $statusCode error for $service with " . + 'the following body: ' . $body, + $statusCode, + $this->urlFor($path) + ); + } + + $message = json_decode($body, true); + if ($message === null) { + throw new HttpException( + "Received a $statusCode error for $service but could " . + 'not decode the response as JSON: ' + . $this->jsonErrorDescription() . ' Body: ' . $body, + $statusCode, + $this->urlFor($path) + ); + } + + if (!isset($message['code']) || !isset($message['error'])) { + throw new HttpException( + 'Error response contains JSON but it does not ' . + 'specify code or error keys: ' . $body, + $statusCode, + $this->urlFor($path) + ); + } + + $this->handleWebServiceError( + $message['error'], + $message['code'], + $statusCode, + $path + ); + } + + /** + * @param string $message the error message from the web service + * @param string $code the error code from the web service + * @param int $statusCode the HTTP status code + * @param string $path the path used in the request + * + * @throws AuthenticationException + * @throws InvalidRequestException + * @throws InsufficientFundsException + */ + private function handleWebServiceError( + $message, + $code, + $statusCode, + $path + ) { + switch ($code) { + case 'IP_ADDRESS_NOT_FOUND': + case 'IP_ADDRESS_RESERVED': + throw new IpAddressNotFoundException( + $message, + $code, + $statusCode, + $this->urlFor($path) + ); + case 'ACCOUNT_ID_REQUIRED': + case 'ACCOUNT_ID_UNKNOWN': + case 'AUTHORIZATION_INVALID': + case 'LICENSE_KEY_REQUIRED': + case 'USER_ID_REQUIRED': + case 'USER_ID_UNKNOWN': + throw new AuthenticationException( + $message, + $code, + $statusCode, + $this->urlFor($path) + ); + case 'OUT_OF_QUERIES': + case 'INSUFFICIENT_FUNDS': + throw new InsufficientFundsException( + $message, + $code, + $statusCode, + $this->urlFor($path) + ); + case 'PERMISSION_REQUIRED': + throw new PermissionRequiredException( + $message, + $code, + $statusCode, + $this->urlFor($path) + ); + default: + throw new InvalidRequestException( + $message, + $code, + $statusCode, + $this->urlFor($path) + ); + } + } + + /** + * @param int $statusCode the HTTP status code + * @param string $service the service name + * @param string $path the URI path used in the request + * + * @throws HttpException + */ + private function handle5xx($statusCode, $service, $path) + { + throw new HttpException( + "Received a server error ($statusCode) for $service", + $statusCode, + $this->urlFor($path) + ); + } + + /** + * @param int $statusCode the HTTP status code + * @param string $service the service name + * @param string $path the URI path used in the request + * + * @throws HttpException + */ + private function handleUnexpectedStatus($statusCode, $service, $path) + { + throw new HttpException( + 'Received an unexpected HTTP status ' . + "($statusCode) for $service", + $statusCode, + $this->urlFor($path) + ); + } + + /** + * @param string $body the successful request body + * @param string $service the service name + * + * @throws WebServiceException if the request body cannot be decoded as + * JSON + * + * @return array the decoded request body + */ + private function handleSuccess($body, $service) + { + if (strlen($body) === 0) { + throw new WebServiceException( + "Received a 200 response for $service but did not " . + 'receive a HTTP body.' + ); + } + + $decodedContent = json_decode($body, true); + if ($decodedContent === null) { + throw new WebServiceException( + "Received a 200 response for $service but could " . + 'not decode the response as JSON: ' + . $this->jsonErrorDescription() . ' Body: ' . $body + ); + } + + return $decodedContent; + } + + private function getCaBundle() + { + $curlVersion = curl_version(); + + // On OS X, when the SSL version is "SecureTransport", the system's + // keychain will be used. + if ($curlVersion['ssl_version'] === 'SecureTransport') { + return; + } + $cert = CaBundle::getSystemCaRootBundlePath(); + + // Check if the cert is inside a phar. If so, we need to copy the cert + // to a temp file so that curl can see it. + if (substr($cert, 0, 7) === 'phar://') { + $tempDir = sys_get_temp_dir(); + $newCert = tempnam($tempDir, 'geoip2-'); + if ($newCert === false) { + throw new \RuntimeException( + "Unable to create temporary file in $tempDir" + ); + } + if (!copy($cert, $newCert)) { + throw new \RuntimeException( + "Could not copy $cert to $newCert: " + . var_export(error_get_last(), true) + ); + } + + // We use a shutdown function rather than the destructor as the + // destructor isn't called on a fatal error such as an uncaught + // exception. + register_shutdown_function( + function () use ($newCert) { + unlink($newCert); + } + ); + $cert = $newCert; + } + if (!file_exists($cert)) { + throw new \RuntimeException("CA cert does not exist at $cert"); + } + + return $cert; + } +} diff --git a/lib/MaxMind/WebService/Http/CurlRequest.php b/lib/MaxMind/WebService/Http/CurlRequest.php new file mode 100644 index 0000000..e44e408 --- /dev/null +++ b/lib/MaxMind/WebService/Http/CurlRequest.php @@ -0,0 +1,110 @@ +url = $url; + $this->options = $options; + } + + /** + * @param $body + * + * @return array + */ + public function post($body) + { + $curl = $this->createCurl(); + + curl_setopt($curl, CURLOPT_POST, true); + curl_setopt($curl, CURLOPT_POSTFIELDS, $body); + + return $this->execute($curl); + } + + public function get() + { + $curl = $this->createCurl(); + + curl_setopt($curl, CURLOPT_HTTPGET, true); + + return $this->execute($curl); + } + + /** + * @return resource + */ + private function createCurl() + { + $curl = curl_init($this->url); + + if (!empty($this->options['caBundle'])) { + $opts[CURLOPT_CAINFO] = $this->options['caBundle']; + } + $opts[CURLOPT_SSL_VERIFYHOST] = 2; + $opts[CURLOPT_FOLLOWLOCATION] = false; + $opts[CURLOPT_SSL_VERIFYPEER] = true; + $opts[CURLOPT_RETURNTRANSFER] = true; + + $opts[CURLOPT_HTTPHEADER] = $this->options['headers']; + $opts[CURLOPT_USERAGENT] = $this->options['userAgent']; + $opts[CURLOPT_PROXY] = $this->options['proxy']; + + // The defined()s are here as the *_MS opts are not available on older + // cURL versions + $connectTimeout = $this->options['connectTimeout']; + if (defined('CURLOPT_CONNECTTIMEOUT_MS')) { + $opts[CURLOPT_CONNECTTIMEOUT_MS] = ceil($connectTimeout * 1000); + } else { + $opts[CURLOPT_CONNECTTIMEOUT] = ceil($connectTimeout); + } + + $timeout = $this->options['timeout']; + if (defined('CURLOPT_TIMEOUT_MS')) { + $opts[CURLOPT_TIMEOUT_MS] = ceil($timeout * 1000); + } else { + $opts[CURLOPT_TIMEOUT] = ceil($timeout); + } + + curl_setopt_array($curl, $opts); + + return $curl; + } + + private function execute($curl) + { + $body = curl_exec($curl); + if ($errno = curl_errno($curl)) { + $errorMessage = curl_error($curl); + + throw new HttpException( + "cURL error ({$errno}): {$errorMessage}", + 0, + $this->url + ); + } + + $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($curl, CURLINFO_CONTENT_TYPE); + curl_close($curl); + + return [$statusCode, $contentType, $body]; + } +} diff --git a/lib/MaxMind/WebService/Http/Request.php b/lib/MaxMind/WebService/Http/Request.php new file mode 100644 index 0000000..27bdd58 --- /dev/null +++ b/lib/MaxMind/WebService/Http/Request.php @@ -0,0 +1,29 @@ +