You are here

IpGeoLocGlobal.php in IP Geolocation Views & Maps 8

File

src/Services/IpGeoLocGlobal.php
View source
<?php

namespace Drupal\ip_geoloc\Services;

use Drupal\Core\Extension\ModuleHandler;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Component\Utility\Unicode;

/**
 * Class IpGeoLocGlobal.
 */
class IpGeoLocGlobal {
  protected $logger;
  protected $ipGeolocSession;
  protected $stringTranslation;
  protected $messenger;
  protected $moduleHandler;
  protected $config;

  /**
   * Constructs a new IpGeoLocGlobal object. Adds dependency injection.
   */
  public function __construct(LoggerChannelFactoryInterface $logger, IpGeoLocSession $ipGeolocSession, TranslationInterface $stringTranslation, MessengerInterface $messenger, ModuleHandler $moduleHandler, ConfigFactoryInterface $config_factory) {
    $this->logger = $logger;
    $this->ipGeolocSession = $ipGeolocSession;
    $this->stringTranslation = $stringTranslation;
    $this->messenger = $messenger;
    $this->moduleHandler = $moduleHandler;
    $this->config = $config_factory
      ->get('ip_geoloc.settings');
  }

  /**
   * Log errors via the watchdog.
   */
  public function logErrors() {
    if ($error = $this->ipGeolocSession
      ->getSessionValue('error')) {

      // @todo How do we treat repeated 'user declined to share location' errors?
      // @TODO migrate debug functoin
      $this->logger
        ->get('IPGV&M')
        ->notice($error);

      // ip_geoloc_debug('IPGV&M, ' . ip_address() . ': ' . $error, 'warning');.
      $this->ipGeolocSession
        ->setSessionValue('error', NULL);
    }
  }

  /**
   * Returns whether this was the first click of the session.
   *
   * @return bool
   *   TRUE if it was, i.e. if there has been no position check before.
   */
  public function isFirstClick() {
    $last_position_check = $this->ipGeolocSession
      ->getSessionValue('last_position_check');
    return empty($last_position_check);
  }

  /**
   * Reinitialises the supplied location array.
   */
  public function reinitLocation(&$location, $reverse_geocode_client_timeout) {
    $location = [
      'fixed_address' => isset($location['fixed_address']) ? (int) $location['fixed_address'] : NULL,
      'regions' => isset($location['regions']) ? $location['regions'] : NULL,
    ];

    // Calls below are synchronous, $location is filled upon return.
    if ($this
      ->useSmartIpIfEnabled($location) || $this
      ->useGeoipApiIfEnabled($location)) {
      if ($reverse_geocode_client_timeout) {
        $this->logger
          ->get('IPGV&M')
          ->notice('Location timeout (waited %sec s). Fallback: %address.', [
          '%sec' => number_format($reverse_geocode_client_timeout, 1),
          '%address' => isset($location['formatted_address']) ? $location['formatted_address'] : '',
        ], WATCHDOG_NOTICE);
      }
    }
    else {
      $this
        ->debug($this->stringTranslation
        ->translate('Smart IP and GeoIP API fallbacks NOT enabled.'));
    }
  }

  /**
   * Use Smart IP (if enabled) to retrieve lat/long and address info.
   *
   * Note that smart_ip_get_location() will invoke
   * hook_smart_ip_get_location_alter($location), which we use to format the
   * address.
   *
   * @param array $location
   *   If $location['ip_address'] isn't filled out the current user's IP address will be used.
   *
   * @return bool
   *   TRUE upon success, FALSE otherwise.
   */
  public function useSmartIpIfEnabled(array &$location) {
    $config = \Drupal::config('ip_geoloc.settings');
    $smart_ip = $this->config
      ->get('ip_geoloc_smart_ip_as_backup') ? $this->config
      ->get('ip_geoloc_smart_ip_as_backup') : FALSE;
    if ($smart_ip) {
      if (function_exists('smart_ip_get_location')) {
        if (empty($location['ip_address'])) {
          $location['ip_address'] = ip_address();
        }
        $fixed_address = isset($location['fixed_address']) ? $location['fixed_address'] : 0;
        $region = isset($location['region']) ? $location['region'] : 0;

        // See also: ip_geoloc_smart_ip_get_location_alter().
        $location = [
          'provider' => 'smart_ip',
          'fixed_address' => $fixed_address,
          'region' => $region,
        ] + smart_ip_get_location($location['ip_address']);
        return TRUE;
      }
      $this
        ->debug($this->stringTranslation
        ->translate('IPGV&M: Smart IP configured as a backup, but is not enabled.'));
    }

    // $location['formatted_address'] = '';.
    return FALSE;
  }

  /**
   * Module GeoIP API does not expose a hook, but it does expose an API.
   *
   * @param array $location
   *   Ff $location['ip_address'] isn't filled out the current user's
   *   IP address will be used.
   *
   * @return bool
   *   TRUE upon success, FALSE otherwise.
   */
  public function useGeoipApiIfEnabled(array &$location) {
    if (!function_exists('geoip_city')) {
      return FALSE;
    }
    $location['provider'] = 'geoip';
    if (empty($location['ip_address'])) {
      $location['ip_address'] = ip_address();
    }
    $geoip_location = (array) geoip_city($location['ip_address']);
    if (reset($geoip_location)) {

      // Where different, convert GeoIP names to our equivalents.
      $geoip_location['country'] = isset($geoip_location['country_name']) ? $geoip_location['country_name'] : '';
      unset($geoip_location['country_name']);
      $location = array_merge($geoip_location, $location);
      ip_geoloc_format_address($location);
    }
    $this
      ->debug($this->stringTranslation
      ->translate('IPGV&M: GeoIP API retrieved: !location', [
      '!location' => ip_geoloc_pretty_print($location),
    ]));
    return TRUE;
  }

  /**
   * Return whether a the visitor's location is due for an update.
   *
   * Updates are only performed on selected configured pages.
   * An update is due when more than a configurable number of seconds have
   * elapsed. If that number is set to zero, then the user's location will be
   * requested until at least the location's country is known, which is
   * normally immediately at the start of the session.
   *
   * @param array $location
   *   Array of location components.
   *
   * @return bool
   *   TRUE if an update is due.
   */
  public function checkLocation(array $location = NULL) {
    if (!$this->config
      ->get('ip_geoloc_google_to_reverse_geocode') ? $this->config
      ->get('ip_geoloc_google_to_reverse_geocode') : FALSE) {
      return FALSE;
    }
    $current_path = \Drupal::service('path.current')
      ->getPath();
    $path_alias = \Drupal::service('path.alias_manager')
      ->getAliasByPath($current_path);
    $path_matcher = \Drupal::service('path.matcher');
    $include_pages = $this->config
      ->get('ip_geoloc_include_pages') ? $this->config
      ->get('ip_geoloc_include_pages') : '*';
    if (!$path_matcher
      ->matchPath($path_alias, $include_pages)) {
      return FALSE;
    }
    $exclude_pages = $this->config
      ->get('ip_geoloc_exclude_pages') ? $this->config
      ->get('ip_geoloc_exclude_pages') : '*';
    if ($path_matcher
      ->matchPath($path_alias, $exclude_pages)) {
      return FALSE;
    }
    $roles_to_reverse_geocode = $this->config
      ->get('ip_geoloc_roles_to_reverse_geocode') ? $this->config
      ->get('ip_geoloc_roles_to_reverse_geocode') : [
      DRUPAL_ANONYMOUS_RID,
      DRUPAL_AUTHENTICATED_RID,
    ];
    $roles_applicable = array_intersect($roles_to_reverse_geocode, array_keys(user_role_names()));
    if (empty($roles_applicable)) {
      return FALSE;
    }
    $interval = (int) $this->config
      ->get('ip_geoloc_location_check_interval') ? $this->config
      ->get('ip_geoloc_location_check_interval') : IP_GEOLOC_LOCATION_CHECK_INTERVAL;
    if ($interval == 0) {
      return !isset($location['latitude']);
    }
    $last_position_check = $this->ipGeolocSession
      ->getSessionValue('last_position_check');
    if (isset($last_position_check)) {
      $time_elapsed = time() - $last_position_check;
      if ($time_elapsed < $interval) {
        $this
          ->debug($this->stringTranslation
          ->translate('IPGV&M: next update scheduled for first click after %seconds seconds (unless overridden or on excluded page).', [
          '%seconds' => $interval - $time_elapsed,
        ]));
        return FALSE;
      }
    }
    return TRUE;
  }

  /**
   * Handle timeout of the Google Maps reverse-geocode callback, if enabled.
   *
   * This is based on $position_pending_since being set to the current time when
   * the service was initiated.
   */
  public function reverseGeocodeTimeout() {
    $pending_since = $this->ipGeolocSession
      ->getSessionValue('position_pending_since');
    if (isset($pending_since)) {
      $time_elapsed = microtime(TRUE) - $pending_since;
      if ($time_elapsed > IP_GEOLOC_CALLBACK_TIMEOUT) {
        $this
          ->debug($this->stringTranslation
          ->translate('IPGV&M timeout: the last reverse-geocode request was @sec s ago.', [
          '@sec' => number_format($time_elapsed, 1),
        ]));
        $this->ipGeolocSession
          ->setSessionValue('position_pending_since', NULL);
        return $time_elapsed;
      }
    }
    return FALSE;
  }

  /**
   * Poor man's address formatter.
   *
   * It doesn't take local format conventions into account. Luckily this is only
   * called as a fallback when lat/long could not be established or the Google
   * reverse-geocode function returned an error.
   *
   * @param array $location
   *   Array of location components.
   */
  public function formatAddress(array &$location) {
    $location['formatted_address'] = isset($location['city']) ? $location['city'] : '';
    if (!empty($location['region'])) {
      $location['formatted_address'] .= ' ' . $location['region'];
    }
    if (!empty($location['postal_code']) && $location['postal_code'] != '-') {
      $location['formatted_address'] .= ' ' . $location['postal_code'] . ',';
    }
    if (!empty($location['country'])) {
      $location['formatted_address'] .= ' ' . $location['country'];
    }
    $location['formatted_address'] = trim($location['formatted_address']);
  }

  /**
   * Fleshes out the $ip_geoloc_address array.
   *
   * This is based on the additional data provided in the $google_address array.
   * This may involve tweaking of the 'latitude' and 'longitude' entries so that
   * they remain consistent with the street address components.
   *
   * @param array $google_address
   *   Array of address components as returned by Google service.
   * @param array $ip_geoloc_address
   *   The $google_address in flattened form.
   *
   * @return bool
   *   TRUE, unless google_address or ip_geoloc_address are empty
   */
  public function flattenGoogleAddress(array $google_address, array &$ip_geoloc_address) {
    if (is_array($google_address) && is_array($google_address['address_components']) && is_array($ip_geoloc_address)) {
      $ip_geoloc_address['provider'] = 'google';
      foreach ($google_address['address_components'] as $component) {
        $long_name = $component['long_name'];
        if (!empty($long_name)) {
          $type = $component['types'][0];
          $ip_geoloc_address[$type] = $long_name;
          if ($type == 'country' && !empty($component['short_name'])) {
            $ip_geoloc_address['country_code'] = $component['short_name'];
          }
        }
      }
      $ip_geoloc_address['formatted_address'] = $google_address['formatted_address'];

      // The following may be slightly different from the original lat,long passed
      // into ip_geoloc_reverse_geocode().
      $ip_geoloc_address['latitude'] = $google_address['geometry']['location']['lat'];
      $ip_geoloc_address['longitude'] = $google_address['geometry']['location']['lng'];
      return TRUE;
    }
    return FALSE;
  }

  /**
   * Print the location array nicely.
   *
   * @param array $location
   *   Array of location components.
   *
   * @return string
   *   The location array formatted as string.
   */
  public function prettyPrint(array $location) {
    $t = '';
    foreach ($location as $label => $value) {
      if (!empty($value)) {
        $t .= SafeMarkup::checkPlain($label) . ':&nbsp;<strong>' . SafeMarkup::checkPlain($value) . '</strong>&nbsp; ';
      }
    }
    return empty($t) ? t('nothing') : $t;
  }

  /**
   * Returns the path to the configured marker directory.
   */
  public function markerDirectory() {
    $path = drupal_get_path('module', 'ip_geoloc');
    $marker_directory = $this->config
      ->get('ip_geoloc_marker_directory');
    $marker_folder = "{$path}/" . $this->moduleHandler
      ->moduleExists('leaflet') ? 'amarkers' : 'markers';
    return $marker_directory ? $marker_directory : $marker_folder;
  }

  /**
   * Return the height and width of the markers in the selected set.
   *
   * @return string
   *   For example '32 x 42' or '21 x 34'
   */
  public function markerDimensions() {
    $dimensions = $this->config
      ->get('ip_geoloc_marker_dimensions');
    if (empty($dimensions)) {
      $directory = $this
        ->markerDirectory();
      $dimensions = strpos($directory, '/amarkers') ? '32 x 42' : '21 x 34';
    }
    return $dimensions;
  }

  /**
   * Return available marker colors for use in a select drop-down. List is compiled based on available .png files in ip_geoloc/markers dir.
   *
   * @return array
   *   Array of color names indexed by machine names
   */
  public function markerColors() {
    $color_list =& drupal_static(__FUNCTION__);
    if (!isset($color_list)) {
      $color_list = [
        '' => '<' . t('default') . '>',
        0 => '<' . t('no marker') . '>',
      ];
      if ($directory_handle = opendir($this
        ->markerDirectory())) {
        while (($filename = readdir($directory_handle)) !== FALSE) {
          if ($ext_pos = strrpos($filename, '.png')) {
            $color = Unicode::substr($filename, 0, $ext_pos);

            // Ok... relies on translations done elsewhere.
            // @TODO dependency injection
            // $color_list[$color] = t($color);
            $color_list[$color] = $color;
          }
        }
        closedir($directory_handle);
      }
      asort($color_list);
    }
    return $color_list;
  }

  /**
   * Return available OpenLayers marker layers for use in a select drop-down.
   *
   * @return array
   *   An array indexed by marker layer number (1..n)
   */
  public function openlayersMarkerLayers() {
    $num_location_marker_layers = $this->config
      ->get('ip_geoloc_num_location_marker_layers') ? $this->config
      ->get('ip_geoloc_num_location_marker_layers') : IP_GEOLOC_DEF_NUM_MARKER_LAYERS;
    $marker_layers = [];
    for ($layer = 1; $layer <= $num_location_marker_layers; $layer++) {
      $marker_layers[$layer] = $this->stringTranslation
        ->translate('Marker layer') . " #{$layer}";
    }
    return $marker_layers;
  }

  /**
   * Determines if a value is within the supplied numeric or alphabetical range.
   *
   * String comparison is based on the ASCII/UTF8 order, so is case-sensitive.
   *
   * @param string $value
   *   The value to check in $range.
   * @param string $range
   *   Of the form '1.5--4.5' (range is inclusive of end points)
   * @param mixed $view_args
   *   The value to check in $range.
   *
   * @return bool
   *   TRUE if the value is in range
   */
  public function isInRange($value, $range, $view_args = NULL) {
    if (!isset($value) || !isset($range)) {
      return FALSE;
    }

    // Defensive programming to make sure we have a string.
    if (is_array($range)) {
      $range = reset($range);
    }
    $from_to = explode(IP_GEOLOC_RANGE_SEPARATOR1, $range);
    if (count($from_to) < 2) {
      $from_to = explode(IP_GEOLOC_RANGE_SEPARATOR2, $range);
    }
    if (($from = _ip_geoloc_extract_value($from_to[0], $view_args)) === NULL) {
      return FALSE;
    }
    if (count($from_to) == 1) {

      // Single value.
      return trim($value) == trim($from);
    }
    if (($to = _ip_geoloc_extract_value($from_to[1], $view_args)) === NULL) {
      return FALSE;
    }
    if ($from == '' && $to == '') {

      // Range separator without values.
      return TRUE;
    }
    if ($from != '' && $to != '') {
      return $value >= $from && $value <= $to;
    }
    if ($from != '') {
      return $value >= $from;
    }
    return $value <= $to;
  }

  /**
   * Extracts a Views argument value from the supplied string.
   *
   * @param string $string
   *   The string to parse.
   * @param array $view_args
   *   The View arguments.
   *
   * @return string
   *   The extracted value.
   */
  public function extractValue($string, array $view_args) {
    if (preg_match('/^!([0-9])/', $string, $matches)) {
      $arg = $matches[1];
      return isset($view_args[$arg - 1]) ? $view_args[$arg - 1] : arg($arg);
    }
    return $string;
  }

  /**
   * FAPI validation of a range element.
   *
   * We want to cover numeric and alphabetic ranges, as well as the special
   * replacement strings !1, !2 ... So we can't be very strict.
   */
  public function rangeWidgetValidate($element, &$form_state) {
    $range = $element['#value'];
    $from_to = explode(IP_GEOLOC_RANGE_SEPARATOR1, $range);
    if (count($from_to) < 2) {
      $from_to = explode(IP_GEOLOC_RANGE_SEPARATOR2, $range);
    }
    if (count($from_to) < 2) {

      // Not a range but a single value. This is ok. If we knew we were checking
      // for a number we would pass the input through is_numeric(), but we don't.
    }
    else {
      $from = trim($from_to[0]);
      $to = trim($from_to[1]);
      if (preg_match('/^![0-9]/', $from) || preg_match('/^![0-9]/', $to)) {
        return;
      }
      $ok = TRUE;

      // If either $from or $to is numeric then assume numeric range and apply
      // validation accordingly.
      if (is_numeric($from) || is_numeric($to)) {

        // If one end is numeric, then the other must also be, or be empty.
        $ok = empty($from) && empty($to) || empty($from) && is_numeric($to) || empty($to) && is_numeric($from) || is_numeric($from) && is_numeric($to) && $from <= $to;
      }
      elseif (!empty($from) && !empty($to)) {

        // Alphabetic range validation.
        $ok = $from <= $to;
      }
      if (!$ok) {
        form_error($element, t('Invalid range.'));
      }
    }
  }

  /**
   * Returns an array of libraries as entered on the config page.
   */
  public function getFontIconLibs() {
    $libs = [];
    for ($i = 1; $i <= IP_GEOLOC_MAX_NUM_FONT_ICON_LIBS; $i++) {
      $file = $this->config
        ->get("ip_geoloc_font_icon_lib{$i}");
      if (!empty($file)) {
        $libs[$i] = $file;
      }
    }
    $known_install = 'sites/all/libraries/font-awesome/css/font-awesome.min.css';
    if (empty($libs) && file_exists($known_install)) {
      $libs[1] = $known_install;
    }
    return $libs;
  }

  /**
   * Returns whether debug is on for the current user.
   */
  public function debugFlag() {
    global $user;
    $current_user = \Drupal::service('current_user');
    $ip_geoloc_debug = $this->config
      ->get('ip_geoloc_debug');
    $user_names = explode(',', $ip_geoloc_debug);
    foreach ($user_names as $user_name) {
      $user_name = mb_strtolower(trim($user_name));
      $match = isset($user->name) ? $user_name == mb_strtolower(trim($user->name)) : $user_name == 'anon' || $user_name == 'anonymous';
      if ($match) {
        return TRUE;
      }
    }
    return FALSE;
  }

  /**
   * Special debug function: messages selected user names only.
   *
   * @param string $message
   *   The message string to bue output as a debug message.
   * @param string $type
   *   Defaults to 'status'.
   *
   * @return array|null
   *   A multidimensional array with keys corresponding to the set message types.
   *   If there are no messages set, the function returns NULL.
   */
  public function debug($message, $type = 'status') {
    if ($this
      ->debugFlag()) {
      return $this->messenger
        ->addMessage($message, $type, FALSE);
    }
  }

  /**
   * Returns true if the previous page was reloaded.
   */
  public function samePath() {
    if (empty($_SERVER['HTTP_REFERER'])) {
      return FALSE;
    }
    $referer = $_SERVER['HTTP_REFERER'];
    global $base_url;
    if (strpos($referer, $base_url) === 0) {
      $prev_path = Unicode::substr($referer, drupal_strlen($base_url) + 1);
      if (empty($prev_path) && drupal_is_front_page()) {
        return TRUE;
      }
      return $prev_path == current_path() || $prev_path == drupal_get_path_alias();
    }
    return FALSE;
  }

}

Classes

Namesort descending Description
IpGeoLocGlobal Class IpGeoLocGlobal.