Drupal 7: Redirecting users based on their country or location for an i18n site

Tom's picture
Comments (12)
Post a new comment
Tom
21 May, 2012 - 16:13

Recently I was tasked with coming up with a system for redirecting users to the correct version of a translated site based on where they were accessing the site from. Having spent some time looking on Google and Drupal.org I did not find anything suitable and so decided to write my own which I am now sharing due to the lack of resources on this subject.

The problem
The client is a multi national business and wants one site where users can select their language, no problem. Actually the site also needs to look up the IP of the user and if their country matches the list of languages that we have set up multi lingual support for then we need to redirect that user silently to the correct page within the site.

I did find a couple of tutorials / demos on this subject but having tried a couple of them none of them actually worked properly so having gathered what I could from these other modules and solutions I decided to write a simple solution using a third party API from ipinfodb.com.

The code
The module code itself may not be the most elegant solution nor does it provide a user interface as for our needs this was pointless and would have been a waste of time but I would consider making this code more user friendly if there was any interest from the community in me doing so.

The Logic
Basically this module tests to see

  • If you have a writable session.
  • If you have already been redirected.
  • Which country you are in.
  • If the URL you requested already has a country code.
  • Which languages are provided for on the site.

Having gathered this information about the user and their request we have to work out some more information about the content on the site like if the page they are requesting actually has a translation in to the required language.

The result is then to either redirect the user or not based on the results of these tests, their location and the availability of translated pages within the site, the desired default behaviour for users that are trying to access the site for a country that is not catered for was to redirect to the default site language so this has been written in as well.

More problems
As we needed to intercept the first request of every user whether the page was cached or not I have had to replace some of the basic native Drupal functions with functional equivalents to get the job done.

How to use this code
If you would like to give this code a try then you will need a Drupal 7 site with internationalisation set up. We opted to use the URL prefix method for differentiating between the languages available so the redirects are hard coded to go to domain.com/LANG_CODE/drupal-path.

You will also need to be relatively proficient a PHP as the code I am providing is in the My Module format as there are only a few functions that you may wish to add to any other custom module.

You will also need to register for a free API key from ipinfodb.com and replace the "#######APIKEY#######" in the code with your API key.

 

  1. /**
  2. * Implements hook_boot().
  3. */
  4. function mymodule_boot() {
  5.  
  6. // an array of country codes to match to their language codes
  7. $countries = array(
  8. 'FR' => 'fr',
  9. 'CN' => 'zh-hans',
  10. 'BR' => 'pt',
  11. 'PT' => 'pt',
  12. 'US' => 'en',
  13. 'NZ' => 'nz',
  14. );
  15.  
  16. // the default lang code, we redirect to this if the users code is not in the
  17. // array of defined options above
  18. $default_lang_code = 'en-gb';
  19.  
  20. // test to make sure the session is writable
  21. $_SESSION['test'] = 'test';
  22. if (empty($_SESSION['test']) || $_SESSION['test'] != 'test') {
  23. return;
  24. }
  25. else {
  26. unset($_SESSION['test']);
  27. }
  28.  
  29. // if there is no country_code session then this is the user's first request
  30. if (empty($_SESSION['country_code'])) {
  31. $key = '#######APIKEY#######';
  32. $request = mymodule_do_curl(mymodule_get_ip_info_db_url($key,  $_SERVER['REMOTE_ADDR']));
  33. $request = !empty($request) ? json_decode($request) : '';
  34. $_SESSION['country_code'] = isset($request->countryCode) ? $request->countryCode : '';
  35. }
  36.  
  37. // if we haven't redirected them yet and they do have a country code session
  38. if (empty($_SESSION['redirected']) && !empty($_SESSION['country_code'])) {
  39.  
  40. global $base_url;
  41.  
  42. // load all active languages
  43. $languages = language_list();
  44.  
  45. // remove the country code from the current get q and save to path URL
  46. $path = explode('/', $_GET['q']);
  47.  
  48. // the current lang code
  49. $current_lang_code = $default_lang_code;
  50. if (count($path) > 0 && array_key_exists($path[0], $languages)) {
  51. $current_lang_code = $path[0];
  52. unset($path[0]);
  53. }
  54. $path = implode('/', $path);
  55.  
  56.  
  57. // if the user does not have a redirect Session
  58. // and does have a country_code session and their country code matches
  59. // one of the pre defined languages and their country code is not the current
  60. // country code in the url then redirect to their correct language
  61. if (
  62. !empty($countries[$_SESSION['country_code']]) &&
  63. !empty($languages[$countries[$_SESSION['country_code']]]) &&
  64. $countries[$_SESSION['country_code']] != $current_lang_code
  65. ) {
  66. $_SESSION['redirected'] = TRUE;
  67.  
  68. // redirect to the correct language home page if this is a homepage request
  69. if (empty($path) && $current_lang_code != $countries[$_SESSION['country_code']]) {
  70. mymodule_redirect($base_url . '/' . $countries[$_SESSION['country_code']], 100);
  71. }
  72.  
  73. // test to see if there is a translated path
  74. $result = db_query("
  75. SELECT a.source, b.alias
  76. FROM url_alias a JOIN url_alias b
  77. ON a.source = b.source
  78. WHERE a.alias = :path
  79. AND b.language = :langcode
  80. ", array(':path' => $path, ':langcode' => $countries[$_SESSION['country_code']]));
  81.  
  82. // if there is a translated path
  83. $result = $result->fetchAssoc();
  84.  
  85. // if there is no translation then get the current base path for the alias
  86. if (empty($result)) {
  87. $result = db_query("
  88. SELECT source
  89. FROM url_alias
  90. WHERE alias = :path
  91. ", array(':path' => $path));
  92. $result = $result->fetchAssoc();
  93. }
  94.  
  95. if (!empty($result['alias']) && $result['alias'] != $path) {
  96. mymodule_redirect($base_url . '/' . $countries[$_SESSION['country_code']] . '/' . $result['alias'], 100);
  97. }
  98. elseif (empty($result['alias']) && !empty($result['source'])) {
  99. mymodule_redirect($base_url . '/' . $countries[$_SESSION['country_code']] . '/' . $result['source'], 100);
  100. }
  101. }
  102. elseif (
  103. (
  104. empty($countries[$_SESSION['country_code']]) ||
  105. empty($languages[$countries[$_SESSION['country_code']]])
  106. ) &&
  107. $current_lang_code != $default_lang_code
  108. ) {
  109. $_SESSION['redirected'] = TRUE;
  110.  
  111. // redirect to the correct language home page if this is a homepage request
  112. if (empty($path) && $current_lang_code != $default_lang_code) {
  113. mymodule_redirect($base_url . '/' . $default_lang_code, 100);
  114. }
  115.  
  116. // test to see if there is a translated path
  117. $result = db_query("
  118. SELECT a.source, b.alias
  119. FROM url_alias a JOIN url_alias b
  120. ON a.source = b.source
  121. WHERE a.alias = :path
  122. AND b.language = :langcode
  123. ", array(':path' => $path, ':langcode' => $default_lang_code));
  124.  
  125. // if there is a translated path
  126. $result = $result->fetchAssoc();
  127.  
  128. // if there is no translation then get the current base path for the alias
  129. if (empty($result)) {
  130. $result = db_query("
  131. SELECT source
  132. FROM url_alias
  133. WHERE alias = :path
  134. ", array(':path' => $path));
  135. $result = $result->fetchAssoc();
  136. }
  137.  
  138. if (!empty($result['alias']) && $result['alias'] != $path) {
  139. mymodule_redirect($base_url . '/' . $default_lang_code . '/' . $result['alias'], 100);
  140. }
  141. elseif (empty($result['alias']) && !empty($result['source'])) {
  142. mymodule_redirect($base_url . '/' . $default_lang_code . '/' . $result['source'], 100);
  143. }
  144. }
  145. }
  146. }
  147.  
  148. /**
  149. * Does a curl request from hook_boot
  150. *
  151. * @param str $url
  152. *   location of resource to get
  153. *
  154. * @return string
  155. */
  156. function mymodule_do_curl($url) {
  157. global $s;
  158. $cookie = tempnam ($s['root_path'] . 'app/cookies', "cookie");
  159. $headers[] = "Accept: text/xml,application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5";
  160. $headers[] = "Connection: keep-alive";
  161. $headers[] = "Keep-Alive: 115";
  162. $headers[] = "Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7";
  163. $headers[] = "Accept-Language: en-us,en;q=0.5";
  164. $headers[] = "Pragma: ";
  165.  
  166. $ch = curl_init();
  167. curl_setopt($ch, CURLOPT_URL, $url);
  168. curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  169. curl_setopt($ch, CURLOPT_USERAGENT,'Mozilla/5.0 (Windows NT 5.1; rv:2.0.1) Gecko/20100101 Firefox/4.0.1');
  170. curl_setopt($ch, CURLOPT_ENCODING, 'gzip,deflate');
  171. curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
  172. curl_setopt($ch, CURLOPT_COOKIEFILE,  $cookie);
  173. curl_setopt($ch, CURLOPT_COOKIEJAR,  $cookie);
  174. curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
  175. $data=curl_exec($ch);
  176. curl_close($ch);
  177. return $data;
  178. }
  179.  
  180. /**
  181. * Custom function to build the api url for the ip location lookup
  182. * @param $ipinfodb_key
  183. * @param $ip_address
  184. * @return string
  185. */
  186. function mymodule_get_ip_info_db_url($ipinfodb_key, $ip_address) {
  187. return "<a href="http://api.ipinfodb.com/v3/ip-city/ip_query.php?key=">http://api.ipinfodb.com/v3/ip-city/ip_query.php?key=</a>$ipinfodb_key&ip=$ip_address&format=json";
  188. }
  189.  
  190. /**
  191. * Redirect function for hook boot
  192. * @param string $url
  193. * @param int $num
  194. *
  195. * @return void
  196. */
  197. function mymodule_redirect($url, $num = 301){
  198. static $http = array (
  199. 100 => "HTTP/1.1 100 Continue",
  200. 101 => "HTTP/1.1 101 Switching Protocols",
  201. 200 => "HTTP/1.1 200 OK",
  202. 201 => "HTTP/1.1 201 Created",
  203. 202 => "HTTP/1.1 202 Accepted",
  204. 203 => "HTTP/1.1 203 Non-Authoritative Information",
  205. 204 => "HTTP/1.1 204 No Content",
  206. 205 => "HTTP/1.1 205 Reset Content",
  207. 206 => "HTTP/1.1 206 Partial Content",
  208. 300 => "HTTP/1.1 300 Multiple Choices",
  209. 301 => "HTTP/1.1 301 Moved Permanently",
  210. 302 => "HTTP/1.1 302 Found",
  211. 303 => "HTTP/1.1 303 See Other",
  212. 304 => "HTTP/1.1 304 Not Modified",
  213. 305 => "HTTP/1.1 305 Use Proxy",
  214. 307 => "HTTP/1.1 307 Temporary Redirect",
  215. 400 => "HTTP/1.1 400 Bad Request",
  216. 401 => "HTTP/1.1 401 Unauthorized",
  217. 402 => "HTTP/1.1 402 Payment Required",
  218. 403 => "HTTP/1.1 403 Forbidden",
  219. 404 => "HTTP/1.1 404 Not Found",
  220. 405 => "HTTP/1.1 405 Method Not Allowed",
  221. 406 => "HTTP/1.1 406 Not Acceptable",
  222. 407 => "HTTP/1.1 407 Proxy Authentication Required",
  223. 408 => "HTTP/1.1 408 Request Time-out",
  224. 409 => "HTTP/1.1 409 Conflict",
  225. 410 => "HTTP/1.1 410 Gone",
  226. 411 => "HTTP/1.1 411 Length Required",
  227. 412 => "HTTP/1.1 412 Precondition Failed",
  228. 413 => "HTTP/1.1 413 Request Entity Too Large",
  229. 414 => "HTTP/1.1 414 Request-URI Too Large",
  230. 415 => "HTTP/1.1 415 Unsupported Media Type",
  231. 416 => "HTTP/1.1 416 Requested range not satisfiable",
  232. 417 => "HTTP/1.1 417 Expectation Failed",
  233. 500 => "HTTP/1.1 500 Internal Server Error",
  234. 501 => "HTTP/1.1 501 Not Implemented",
  235. 502 => "HTTP/1.1 502 Bad Gateway",
  236. 503 => "HTTP/1.1 503 Service Unavailable",
  237. 504 => "HTTP/1.1 504 Gateway Time-out"
  238. );
  239.  
  240. header($http[$num]);
  241. header ("Location: $url");
  242. exit();
  243. }
12

Comments

marco's picture
marco22 May, 2012 - 12:32

so you auto redirect the user based on ip? isnt that bad for google crawlers that try to index your site?

Tom's picture
Tom22 May, 2012 - 12:38
I did think of that at the time but the client wants what the client wants. Google crawlers do one hit then another etc and there is no session data persisted from one hit to the next as far as I know but the other translated pages are available after the first hit.
Anonymous's picture
Anonymous31 May, 2012 - 21:56

So you are assuming a users language based on IP - good for UX?

drupal development's picture
drupal development2 June, 2012 - 08:09

yes users language based on IP - good for UX...

Deloris's picture
Deloris18 May, 2013 - 13:04

I hardly leave a response, but i did some searching and wound up here Add new comment | Tigerfish Interactive.
And I actually do have 2 questions for you if you tend not to
mind. Could it be simply me or does it look like a few of these remarks
appear as if they are left by brain dead individuals?
:-P And, if you are posting at other social sites, I'd like to follow everything new you have to post. Would you make a list of the complete urls of your shared pages like your twitter feed, Facebook page or linkedin profile?

Here is my homepage staystrongrelationships.com

Ben's picture
Ben17 August, 2012 - 14:24

Thanks for putting this together. I'm looking to do something almost identical actually. I'm curious as to whether or not you looked into the Smart IP module (http://drupal.org/project/smart_ip/) and if so, why you decided not to use it. The main difference I see is that the Smart IP module doesn't necessarily use the Internationalization module, but I'm curious to hear your thoughts on this.

Tom's picture
Tom17 August, 2012 - 15:59

Hi Ben, I did look at that module but unfortunately I can't remember why I didn't use it. It may have been because it did not redirect the users and if it doesn't support multilingual sites that would have been another reason.

The requirements for this were pretty specific and I often find it quicker and easier to write a module that does exactly what I need rather than sift through the contributed modules that almost always do not do exactly what I need and inevitably add unnecessary overhead.

If you were going to put this in to play on another site you may want to give the user an option to go to their translated version rather than assuming they want to in the form of a lightbox or similar as the current implementation is not good for SEO as Google will possibly struggle to crawl the non US pages as their server are all based in the US.

Jon Muir's picture
Jon Muir2 October, 2012 - 12:17

Nice article Tom, that is a very useful solution.

My only concern which I would look to resolve is the curl call each time to the remote service.

To resolve this you can download from another source monthly/weekly updates to the IP registry (see links below) import them into your DB and handle everything locally.

ftp://ftp.ripe.net/pub/stats/arin/delegated-arin-latest
ftp://ftp.ripe.net/pub/stats/apnic/delegated-apnic-latest
ftp://ftp.ripe.net/pub/stats/lacnic/delegated-lacnic-latest
ftp://ftp.ripe.net/pub/stats/afrinic/delegated-afrinic-latest
ftp://ftp.ripe.net/pub/stats/ripencc/delegated-ripencc-latest

Food for thought, regardless though, good job!

Tanmay's picture
Tanmay19 October, 2012 - 13:50

Its nice solution. But please do have a look at http://drupal.org/project/geo_redirect :-)

Julie's picture
Julie13 November, 2012 - 23:03

"Limitation: Doesn't work for internal URLs like http://example.com/node/2"

maybe that's the reason why... ?

richardboss78's picture
richardboss7822 October, 2012 - 13:27

Great work. Thanks for share this work. Its a very nice to share. Thanks for share this information.

kladoiskatel's picture
kladoiskatel7 February, 2013 - 01:40

comment_body[und][0][value]

Add new comment