1<?php
2
3/**
4 * Observium
5 *
6 *   This file is part of Observium.
7 *
8 * @package    observium
9 * @subpackage geolocation
10 * @author     Adam Armstrong <adama@observium.org>
11 * @copyright  (C) 2006-2013 Adam Armstrong, (C) 2013-2019 Observium Limited
12 *
13 */
14
15// This function returns an array of location data when given an address.
16// The open&free geocoding APIs are not very flexible, so addresses must be in standard formats.
17
18// DOCME needs phpdoc block
19// TESTME needs unit testing
20function get_geolocation($address, $geo_db = array(), $dns_only = FALSE)
21{
22  global $config;
23
24  $ok       = FALSE;
25  $address  = trim($address);
26  $location = array('location' => $address); // Init location array
27  $location['location_geoapi'] = strtolower(trim($config['geocoding']['api']));
28  if (!isset($config['geo_api'][$location['location_geoapi']]))
29  {
30    // Use default if unknown api
31    $location['location_geoapi'] = 'geocodefarm';
32  }
33
34  // v3 of geo definitions :D
35  $geo_def = $config['geo_api'][$location['location_geoapi']];
36
37  // API Rate limit
38  $ratelimit = FALSE;
39  if (strlen($config['geocoding']['api_key']) && isset($geo_def['ratelimit_key']))
40  {
41    // Ratelimit if used api key
42    $ratelimit = $geo_def['ratelimit_key'];
43  }
44  elseif (isset($geo_def['ratelimit']))
45  {
46    $ratelimit = $geo_def['ratelimit'];
47  }
48
49  if (isset($config['geocoding']['enable']) && $config['geocoding']['enable'])
50  {
51    $geo_type = 'forward'; // by default forward geocoding
52    $debug_msg = "Geocoding ENABLED, try detect device coordinates:".PHP_EOL;
53
54    // If device coordinates set manually, use Reverse Geocoding.
55    if ($geo_db['location_manual'])
56    {
57      $location['location_lat'] = $geo_db['location_lat'];
58      $location['location_lon'] = $geo_db['location_lon'];
59      $geo_type = 'reverse';
60      $debug_msg .= '  MANUAL coordinates - SET'.PHP_EOL;
61    }
62    // If DNS LOC support is enabled and DNS LOC record is set, use Reverse Geocoding.
63    elseif ($config['geocoding']['dns'])
64    {
65      /**
66       * Ack! dns_get_record not only cannot retrieve LOC records, but it also actively filters them when using
67       * DNS_ANY as query type (which, admittedly would not be all that reliable as per the manual).
68       *
69       * Example LOC:
70       *   "20 31 55.893 N 4 57 38.269 E 45.00m 10m 100m 10m"
71       *
72       * From Wikipedia: d1 [m1 [s1]] {"N"|"S"}  d2 [m2 [s2]] {"E"|"W"}
73       *
74       * Parsing this is something for Net_DNS2 as it has the code for it.
75       */
76      if ($geo_db['hostname'])
77      {
78        //include_once('Net/DNS2.php');
79        //include_once('Net/DNS2/RR/LOC.php');
80
81        $resolver = new Net_DNS2_Resolver();
82        try {
83          $response = $resolver->query($geo_db['hostname'], 'LOC', 'IN');
84        } catch(Net_DNS2_Exception $e) {
85          $response = FALSE;
86          print_debug('  Resolver error: '.$e->getMessage().' (hostname: '.$geo_db['hostname'].')');
87        }
88      } else {
89        $response = FALSE;
90        print_debug("  DNS LOC enabled, but device hostname empty.");
91      }
92      if ($response)
93      {
94        print_debug_vars($response->answer);
95        foreach ($response->answer as $answer)
96        {
97          if (is_numeric($answer->latitude) && is_numeric($answer->longitude))
98          {
99            $location['location_lat'] = $answer->latitude;
100            $location['location_lon'] = $answer->longitude;
101            $geo_type = 'reverse';
102            break;
103          }
104          else if (is_numeric($answer->degree_latitude) && is_numeric($answer->degree_longitude))
105          {
106            $ns_multiplier = ($answer->ns_hem == 'N' ? 1 : -1);
107            $ew_multiplier = ($answer->ew_hem == 'E' ? 1 : -1);
108
109            $location['location_lat'] = round($answer->degree_latitude + $answer->min_latitude/60 + $answer->sec_latitude/3600,7) * $ns_multiplier;
110            $location['location_lon'] = round($answer->degree_longitude + $answer->min_longitude/60 + $answer->sec_longitude/3600,7) * $ew_multiplier;
111            $geo_type = 'reverse';
112            break;
113          }
114        }
115        if (isset($location['location_lat']))
116        {
117          $debug_msg .= '  DNS LOC records - FOUND'.PHP_EOL;
118        } else {
119          $debug_msg .= '  DNS LOC records - NOT FOUND'.PHP_EOL;
120          if ($dns_only)
121          {
122            // If we check only DNS LOC records but it not found, exit
123            print_debug($debug_msg);
124            return FALSE;
125          }
126        }
127      }
128    }
129
130    /**
131     * If location string contains coordinates use Reverse Geocoding.
132     * Valid strings:
133     *   Some location [33.234, -56.22]
134     *   Some location (33.234 -56.22)
135     *   Some location [33.234;-56.22]
136     *   33.234,-56.22
137     */
138    $pattern = '/(?:^|[\[(])\s*(?<lat>[+-]?\d+(?:\.\d+)*)\s*[,:; ]\s*(?<lon>[+-]?\d+(?:\.\d+)*)\s*(?:[\])]|$)/';
139    if ($geo_type == 'forward' && preg_match($pattern, $address, $matches))
140    {
141      if ($matches['lat'] >= -90  && $matches['lat'] <= 90 &&
142          $matches['lon'] >= -180 && $matches['lon'] <= 180)
143      {
144        $location['location_lat'] = $matches['lat'];
145        $location['location_lon'] = $matches['lon'];
146        $geo_type = 'reverse';
147      }
148    }
149
150    // Excluded bad location strings like <none>, Unknown, ''
151    $valid_address = strlen($address) > 4 && !preg_match('/^[<\\\(]?(unknown|private|none|office|location|snmplocation)[>\\\)]?$/i', $address);
152    if ($geo_type == 'reverse' || $valid_address)
153    {
154      // We have correct non empty address or reverse coordinates
155
156      // Have api specific file include and definition?
157      $is_geo_def = isset($geo_def[$geo_type]);
158      $is_geo_file = is_file($config['install_dir'] . '/includes/geolocation/' . $location['location_geoapi'] . '.inc.php');
159
160      if ($geo_type == 'reverse')
161      {
162        $debug_msg .= '  by REVERSE query (API: '.strtoupper($config['geocoding']['api']).', LAT: '.$location['location_lat'].', LON: '.$location['location_lon'].') - ';
163      } else {
164        $debug_msg .= '  by FORWARD query (API: '.strtoupper($config['geocoding']['api']).', sysLocation: ' . $address . ') - ';
165      }
166
167      if ($is_geo_def)
168      {
169        // Generate geolocation tags, used for rewrites in definition
170        $tags = generate_geolocation_tags($location['location_geoapi'], $location);
171
172        // Generate context/options with encoded data and geo specific api headers
173        $options = generate_http_context($geo_def[$geo_type], $tags);
174
175        // API URL to POST to
176        $url = generate_http_url($geo_def[$geo_type], $tags);
177
178        // First request
179        $mapresponse = get_http_request($url, $options, $ratelimit);
180
181        // Send out API call and parse response
182        if (!test_http_request($geo_def[$geo_type], $mapresponse))
183        {
184          // False response
185          $geo_status = strtoupper($GLOBALS['response_headers']['status']);
186          $debug_msg .= $geo_status . PHP_EOL;
187          if (OBS_DEBUG < 2 && strlen($tags['key']))
188          {
189            // Hide API KEY from output
190            $url = str_replace('=' . $tags['key'], '=***', $url);
191          }
192          $debug_msg .= '  GEO API REQUEST: ' . $url;
193          print_debug($debug_msg);
194          // Return old array with new status (for later recheck)
195          unset($geo_db['hostname'], $geo_db['location_updated']);
196          $location['location_status']  = $debug_msg;
197          $location['location_updated'] = format_unixtime($config['time']['now'], 'Y-m-d G:i:s');
198          //print_vars($location);
199          //print_vars($geo_db);
200          return array_merge($geo_db, $location);
201        }
202
203        switch ($geo_def[$geo_type]['response_format'])
204        {
205          case 'xml':
206            // Hrm, currently unused
207            break;
208          case 'json':
209          default:
210            $data = json_decode($mapresponse, TRUE);
211        }
212
213        if ($geo_type == 'forward')
214        {
215          // We seem to have hit a snag geocoding. It might be that the first element of the address is a business name.
216          // Lets drop the first element and see if we get anything better! This works more often than one might expect.
217          if (str_contains($address, ' - '))
218          {
219            // Rack: NK-76 - Nikhef, Amsterdam, NL
220            list(, $address_second) = explode(' - ', $address, 2);
221            $address_second = trim($address_second);
222          }
223          elseif (str_contains($address, ','))
224          {
225            // ZRH2, Badenerstrasse 569, Zurich, Switzerland
226            list(, $address_second) = explode(',', $address, 2);
227            $address_second = trim($address_second);
228          } else {
229            $address_second = NULL;
230          }
231
232          // Coordinates not found, try second request
233          if (isset($geo_def[$geo_type]['location_lat']) &&
234              !strlen(array_get_nested($data, $geo_def[$geo_type]['location_lat'])) &&
235              strlen($address_second) > 4)
236          {
237
238            // Re-Generate geolocation tags, override location
239            $tags['location'] = $address_second;
240
241            // Generate context/options with encoded data and geo specific api headers
242            $options_new = generate_http_context($geo_def[$geo_type], $tags);
243
244            // API URL to POST to
245            $url_new = generate_http_url($geo_def[$geo_type], $tags);
246
247            // Second request
248            $mapresponse = get_http_request($url_new, $options_new, $ratelimit);
249            if (test_http_request($geo_def[$geo_type], $mapresponse))
250            {
251              // Valid response
252              $data_new = json_decode($mapresponse, TRUE);
253              if (strlen(array_get_nested($data, $geo_def[$geo_type]['location_lat'])))
254              {
255                $data = $data_new;
256                $url = $url_new;
257              }
258            }
259          }
260        } // End second forward request
261
262      }
263
264      //print_vars($data);
265      $geo_status  = 'NOT FOUND';
266
267      if ($is_geo_file)
268      {
269        // API specific parser
270        require($config['install_dir'] . '/includes/geolocation/' . $location['location_geoapi'] . '.inc.php');
271
272        if ($data === FALSE)
273        {
274          // Return old array with new status (for later recheck)
275          unset($geo_db['hostname'], $geo_db['location_updated']);
276          //$location['location_status']  = $debug_msg;
277          $location['location_updated'] = format_unixtime($config['time']['now'], 'Y-m-d G:i:s');
278          //print_vars($location);
279          //print_vars($geo_db);
280          return array_merge($geo_db, $location);
281        }
282
283      }
284      elseif ($is_geo_def)
285      {
286        // Set lat/lon and others by definitions
287        if ($geo_type == 'forward')
288        {
289          $location['location_lat'] = array_get_nested($data, $geo_def[$geo_type]['location_lat']);
290          $location['location_lon'] = array_get_nested($data, $geo_def[$geo_type]['location_lon']);
291        }
292        foreach (['city', 'county', 'state', 'country'] as $entry)
293        {
294          $param = 'location_' . $entry;
295          foreach ((array)$geo_def[$geo_type][$param] as $field)
296          {
297            // Possible to use multiple fields, use first not empty
298            if ($location[$param] = array_get_nested($data, $field)) { break; }
299          }
300        }
301      }
302
303      print_debug_vars($data);
304    } else {
305      $geo_status  = 'NOT REQUESTED';
306    }
307  }
308
309  // Use defaults if empty values
310  if (!strlen($location['location_lat']) || !strlen($location['location_lon']))
311  {
312    // Reset to empty coordinates
313    $location['location_lat'] = array('NULL');
314    $location['location_lon'] = array('NULL');
315    //$location['location_lat'] = $config['geocoding']['default']['lat'];
316    //$location['location_lon'] = $config['geocoding']['default']['lon'];
317    //if (is_numeric($config['geocoding']['default']['lat']) && is_numeric($config['geocoding']['default']['lon']))
318    //{
319    //  $location['location_manual']     = 1; // Set manual key for ability reset from WUI
320    //}
321  } else {
322    // Always round lat/lon same as DB precision (DECIMAL(10,7))
323    $location['location_lat'] = round($location['location_lat'], 7);
324    $location['location_lon'] = round($location['location_lon'], 7);
325  }
326
327  // Remove duplicate County/State words
328  foreach (array('city', 'county', 'state') as $entry)
329  {
330    $param = 'location_' . $entry;
331    $location[$param] = strlen($location[$param]) ? str_ireplace(' '.$entry, '', $location[$param]) : 'Unknown';
332  }
333  // Unificate Country name
334  if (strlen($location['location_country']))
335  {
336    $location['location_country'] = country_from_code($location['location_country']);
337    $geo_status = 'FOUND';
338  } else {
339    $location['location_country'] = 'Unknown';
340  }
341
342  // Print some debug information
343  $debug_msg .= $geo_status . PHP_EOL;
344  if (OBS_DEBUG < 2 && strlen($tags['key']))
345  {
346    // Hide API KEY from output
347    $url = str_replace('=' . $tags['key'], '=***', $url);
348  }
349  $debug_msg .= '  GEO API REQUEST: ' . $url;
350
351  if ($geo_status == 'FOUND')
352  {
353    $debug_msg .= PHP_EOL . '  GEO LOCATION: ';
354    $debug_msg .= $location['location_country'].' (Country), '.$location['location_state'].' (State), ';
355    $debug_msg .= $location['location_county'] .' (County), ' .$location['location_city'] .' (City)';
356    $debug_msg .= PHP_EOL . '  GEO COORDINATES: ';
357    $debug_msg .= $location['location_lat'] .' (Latitude), ' .$location['location_lon'] .' (Longitude)';
358  } else {
359    $debug_msg .= PHP_EOL . '  QUERY DATE: '.date('r'); // This is requered for increase data in DB
360  }
361  print_debug($debug_msg);
362  $location['location_status'] = $debug_msg;
363
364  return $location;
365}
366
367/**
368 * Generate geolocation tags, used for transform any other parts of geo api definition.
369 *
370 * @global array $config
371 * @param string $api      GEO api key (see geo definitions)
372 * @param array  $tags     (optional) Location array and other tags
373 * @param array  $params   (optional) Array of requested params with key => value entries (used with request method POST)
374 * @param string $location (optional) Location string, if passed override location tag
375 * @return array           HTTP Context which can used in get_http_request_test() or get_http_request()
376 */
377function generate_geolocation_tags($api, $tags = array(), $params = array(), $location = NULL)
378{
379  global $config;
380
381  $tags = array_merge($tags, $params);
382
383  // Override location tag if passed as argument
384  if (strlen($location))
385  {
386    $tags['location'] = $location;
387  }
388  // Add lat/lon tags
389  if (isset($tags['location_lon']))
390  {
391    $tags['lon'] = $tags['location_lon'];
392  }
393  if (isset($tags['location_lon']))
394  {
395    $tags['lat'] = $tags['location_lat'];
396  }
397
398  // Common used params for geo apis
399  $tags['id'] = OBSERVIUM_PRODUCT . '-' . substr(get_unique_id(), 0, 8);
400  $tags['uuid'] = get_unique_id();
401
402  // API key if not empty
403  if (isset($config['geo_api'][$api]['key']) && strlen($config['geo_api'][$api]['key']))
404  {
405    // Ability to store API keys for each GEO API separately
406    $tags['key'] = escape_html($config['geo_api'][$api]['key']); // KEYs is never used special characters
407  }
408  elseif (strlen($config['geocoding']['api_key']))
409  {
410    $tags['key'] = escape_html($config['geocoding']['api_key']); // KEYs is never used special characters
411  }
412
413  //print_vars($tags);
414  return $tags;
415}
416
417// EOF
418