Sanitise parameters used in URL generation
[rails.git] / app / controllers / geocoder_controller.rb
1 # coding: utf-8
2
3 class GeocoderController < ApplicationController
4   require "cgi"
5   require "uri"
6   require "rexml/document"
7
8   before_action :authorize_web
9   before_action :set_locale
10   before_action :require_oauth, :only => [:search]
11
12   def search
13     @params = normalize_params
14     @sources = []
15
16     if @params[:lat] && @params[:lon]
17       @sources.push "latlon"
18       @sources.push "osm_nominatim_reverse"
19       @sources.push "geonames_reverse" if defined?(GEONAMES_USERNAME)
20     elsif @params[:query]
21       if @params[:query] =~ /^\d{5}(-\d{4})?$/
22         @sources.push "us_postcode"
23         @sources.push "osm_nominatim"
24       elsif @params[:query] =~ /^(GIR 0AA|[A-PR-UWYZ]([0-9]{1,2}|([A-HK-Y][0-9]|[A-HK-Y][0-9]([0-9]|[ABEHMNPRV-Y]))|[0-9][A-HJKS-UW])\s*[0-9][ABD-HJLNP-UW-Z]{2})$/i
25         @sources.push "uk_postcode"
26         @sources.push "osm_nominatim"
27       elsif @params[:query] =~ /^[A-Z]\d[A-Z]\s*\d[A-Z]\d$/i
28         @sources.push "ca_postcode"
29         @sources.push "osm_nominatim"
30       else
31         @sources.push "osm_nominatim"
32         @sources.push "geonames" if defined?(GEONAMES_USERNAME)
33       end
34     end
35
36     if @sources.empty?
37       head :bad_request
38     else
39       render :layout => map_layout
40     end
41   end
42
43   def search_latlon
44     lat = params[:lat].to_f
45     lon = params[:lon].to_f
46     if lat < -90 || lat > 90
47       @error = "Latitude #{lat} out of range"
48       render :action => "error"
49     elsif lon < -180 || lon > 180
50       @error = "Longitude #{lon} out of range"
51       render :action => "error"
52     else
53       @results = [{ :lat => lat, :lon => lon,
54                     :zoom => params[:zoom],
55                     :name => "#{lat}, #{lon}" }]
56
57       render :action => "results"
58     end
59   end
60
61   def search_us_postcode
62     # get query parameters
63     query = params[:query]
64
65     # create result array
66     @results = []
67
68     # ask geocoder.us (they have a non-commercial use api)
69     response = fetch_text("http://rpc.geocoder.us/service/csv?zip=#{escape_query(query)}")
70
71     # parse the response
72     unless response =~ /couldn't find this zip/
73       data = response.split(/\s*,\s+/) # lat,long,town,state,zip
74       @results.push(:lat => data[0], :lon => data[1],
75                     :zoom => POSTCODE_ZOOM,
76                     :prefix => "#{data[2]}, #{data[3]},",
77                     :name => data[4])
78     end
79
80     render :action => "results"
81   rescue StandardError => ex
82     @error = "Error contacting rpc.geocoder.us: #{ex}"
83     render :action => "error"
84   end
85
86   def search_uk_postcode
87     # get query parameters
88     query = params[:query]
89
90     # create result array
91     @results = []
92
93     # ask npemap.org.uk to do a combined npemap + freethepostcode search
94     response = fetch_text("http://www.npemap.org.uk/cgi/geocoder.fcgi?format=text&postcode=#{escape_query(query)}")
95
96     # parse the response
97     unless response =~ /Error/
98       dataline = response.split(/\n/)[1]
99       data = dataline.split(/,/) # easting,northing,postcode,lat,long
100       postcode = data[2].delete("'")
101       zoom = POSTCODE_ZOOM - postcode.count("#")
102       @results.push(:lat => data[3], :lon => data[4], :zoom => zoom,
103                     :name => postcode)
104     end
105
106     render :action => "results"
107   rescue StandardError => ex
108     @error = "Error contacting www.npemap.org.uk: #{ex}"
109     render :action => "error"
110   end
111
112   def search_ca_postcode
113     # get query parameters
114     query = params[:query]
115     @results = []
116
117     # ask geocoder.ca (note - they have a per-day limit)
118     response = fetch_xml("http://geocoder.ca/?geoit=XML&postal=#{escape_query(query)}")
119
120     # parse the response
121     if response.get_elements("geodata/error").empty?
122       @results.push(:lat => response.get_text("geodata/latt").to_s,
123                     :lon => response.get_text("geodata/longt").to_s,
124                     :zoom => POSTCODE_ZOOM,
125                     :name => query.upcase)
126     end
127
128     render :action => "results"
129   rescue StandardError => ex
130     @error = "Error contacting geocoder.ca: #{ex}"
131     render :action => "error"
132   end
133
134   def search_osm_nominatim
135     # get query parameters
136     query = params[:query]
137     minlon = params[:minlon]
138     minlat = params[:minlat]
139     maxlon = params[:maxlon]
140     maxlat = params[:maxlat]
141
142     # get view box
143     if minlon && minlat && maxlon && maxlat
144       viewbox = "&viewbox=#{minlon},#{maxlat},#{maxlon},#{minlat}"
145     end
146
147     # get objects to excude
148     exclude = "&exclude_place_ids=#{params[:exclude]}" if params[:exclude]
149
150     # ask nominatim
151     response = fetch_xml("http:#{NOMINATIM_URL}search?format=xml&extratags=1&q=#{escape_query(query)}#{viewbox}#{exclude}&accept-language=#{http_accept_language.user_preferred_languages.join(',')}")
152
153     # extract the results from the response
154     results =  response.elements["searchresults"]
155
156     # extract parameters from more_url
157     more_url_params = CGI.parse(URI.parse(results.attributes["more_url"]).query)
158
159     # create result array
160     @results = []
161
162     # create parameter hash for "more results" link
163     @more_params = params.merge(:exclude => more_url_params["exclude_place_ids"].first)
164
165     # parse the response
166     results.elements.each("place") do |place|
167       lat = place.attributes["lat"].to_s
168       lon = place.attributes["lon"].to_s
169       klass = place.attributes["class"].to_s
170       type = place.attributes["type"].to_s
171       name = place.attributes["display_name"].to_s
172       min_lat, max_lat, min_lon, max_lon = place.attributes["boundingbox"].to_s.split(",")
173       prefix_name = if type.empty?
174                       ""
175                     else
176                       t "geocoder.search_osm_nominatim.prefix.#{klass}.#{type}", :default => type.tr("_", " ").capitalize
177                     end
178       if klass == "boundary" && type == "administrative"
179         rank = (place.attributes["place_rank"].to_i + 1) / 2
180         prefix_name = t "geocoder.search_osm_nominatim.admin_levels.level#{rank}", :default => prefix_name
181         place.elements["extratags"].elements.each("tag") do |extratag|
182           if extratag.attributes["key"] == "place"
183             prefix_name = t "geocoder.search_osm_nominatim.prefix.place.#{extratag.attributes['value']}", :default => prefix_name
184           end
185         end
186       end
187       prefix = t "geocoder.search_osm_nominatim.prefix_format", :name => prefix_name
188       object_type = place.attributes["osm_type"]
189       object_id = place.attributes["osm_id"]
190
191       @results.push(:lat => lat, :lon => lon,
192                     :min_lat => min_lat, :max_lat => max_lat,
193                     :min_lon => min_lon, :max_lon => max_lon,
194                     :prefix => prefix, :name => name,
195                     :type => object_type, :id => object_id)
196     end
197
198     render :action => "results"
199   rescue StandardError => ex
200     @error = "Error contacting nominatim.openstreetmap.org: #{ex}"
201     render :action => "error"
202   end
203
204   def search_geonames
205     # get query parameters
206     query = params[:query]
207
208     # get preferred language
209     lang = I18n.locale.to_s.split("-").first
210
211     # create result array
212     @results = []
213
214     # ask geonames.org
215     response = fetch_xml("http://api.geonames.org/search?q=#{escape_query(query)}&lang=#{lang}&maxRows=20&username=#{GEONAMES_USERNAME}")
216
217     # parse the response
218     response.elements.each("geonames/geoname") do |geoname|
219       lat = geoname.get_text("lat").to_s
220       lon = geoname.get_text("lng").to_s
221       name = geoname.get_text("name").to_s
222       country = geoname.get_text("countryName").to_s
223       @results.push(:lat => lat, :lon => lon,
224                     :zoom => GEONAMES_ZOOM,
225                     :name => name,
226                     :suffix => ", #{country}")
227     end
228
229     render :action => "results"
230   rescue StandardError => ex
231     @error = "Error contacting api.geonames.org: #{ex}"
232     render :action => "error"
233   end
234
235   def search_osm_nominatim_reverse
236     # get query parameters
237     lat = params[:lat]
238     lon = params[:lon]
239     zoom = params[:zoom]
240
241     # create result array
242     @results = []
243
244     # ask nominatim
245     response = fetch_xml("http:#{NOMINATIM_URL}reverse?lat=#{lat}&lon=#{lon}&zoom=#{zoom}&accept-language=#{http_accept_language.user_preferred_languages.join(',')}")
246
247     # parse the response
248     response.elements.each("reversegeocode/result") do |result|
249       lat = result.attributes["lat"].to_s
250       lon = result.attributes["lon"].to_s
251       object_type = result.attributes["osm_type"]
252       object_id = result.attributes["osm_id"]
253       description = result.get_text.to_s
254
255       @results.push(:lat => lat, :lon => lon,
256                     :zoom => zoom,
257                     :name => description,
258                     :type => object_type, :id => object_id)
259     end
260
261     render :action => "results"
262   rescue StandardError => ex
263     @error = "Error contacting nominatim.openstreetmap.org: #{ex}"
264     render :action => "error"
265   end
266
267   def search_geonames_reverse
268     # get query parameters
269     lat = params[:lat]
270     lon = params[:lon]
271
272     # get preferred language
273     lang = I18n.locale.to_s.split("-").first
274
275     # create result array
276     @results = []
277
278     # ask geonames.org
279     response = fetch_xml("http://api.geonames.org/countrySubdivision?lat=#{lat}&lng=#{lon}&lang=#{lang}&username=#{GEONAMES_USERNAME}")
280
281     # parse the response
282     response.elements.each("geonames/countrySubdivision") do |geoname|
283       name = geoname.get_text("adminName1").to_s
284       country = geoname.get_text("countryName").to_s
285       @results.push(:lat => lat, :lon => lon,
286                     :zoom => GEONAMES_ZOOM,
287                     :name => name,
288                     :suffix => ", #{country}")
289     end
290
291     render :action => "results"
292   rescue StandardError => ex
293     @error = "Error contacting api.geonames.org: #{ex}"
294     render :action => "error"
295   end
296
297   private
298
299   def fetch_text(url)
300     response = OSM.http_client.get(URI.parse(url))
301
302     if response.success?
303       response.body
304     else
305       raise response.status.to_s
306     end
307   end
308
309   def fetch_xml(url)
310     REXML::Document.new(fetch_text(url))
311   end
312
313   def escape_query(query)
314     URI.escape(query, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]", false, "N"))
315   end
316
317   def normalize_params
318     if query = params[:query]
319       query.strip!
320
321       if latlon = query.match(/^([NS])\s*(\d{1,3}(\.\d*)?)\W*([EW])\s*(\d{1,3}(\.\d*)?)$/).try(:captures) # [NSEW] decimal degrees
322         params.merge!(nsew_to_decdeg(latlon)).delete(:query)
323       elsif latlon = query.match(/^(\d{1,3}(\.\d*)?)\s*([NS])\W*(\d{1,3}(\.\d*)?)\s*([EW])$/).try(:captures) # decimal degrees [NSEW]
324         params.merge!(nsew_to_decdeg(latlon)).delete(:query)
325
326       elsif latlon = query.match(/^([NS])\s*(\d{1,3})°?\s*(\d{1,3}(\.\d*)?)?['′]?\W*([EW])\s*(\d{1,3})°?\s*(\d{1,3}(\.\d*)?)?['′]?$/).try(:captures) # [NSEW] degrees, decimal minutes
327         params.merge!(ddm_to_decdeg(latlon)).delete(:query)
328       elsif latlon = query.match(/^(\d{1,3})°?\s*(\d{1,3}(\.\d*)?)?['′]?\s*([NS])\W*(\d{1,3})°?\s*(\d{1,3}(\.\d*)?)?['′]?\s*([EW])$/).try(:captures) # degrees, decimal minutes [NSEW]
329         params.merge!(ddm_to_decdeg(latlon)).delete(:query)
330
331       elsif latlon = query.match(/^([NS])\s*(\d{1,3})°?\s*(\d{1,2})['′]?\s*(\d{1,3}(\.\d*)?)?["″]?\W*([EW])\s*(\d{1,3})°?\s*(\d{1,2})['′]?\s*(\d{1,3}(\.\d*)?)?["″]?$/).try(:captures) # [NSEW] degrees, minutes, decimal seconds
332         params.merge!(dms_to_decdeg(latlon)).delete(:query)
333       elsif latlon = query.match(/^(\d{1,3})°?\s*(\d{1,2})['′]?\s*(\d{1,3}(\.\d*)?)?["″]\s*([NS])\W*(\d{1,3})°?\s*(\d{1,2})['′]?\s*(\d{1,3}(\.\d*)?)?["″]?\s*([EW])$/).try(:captures) # degrees, minutes, decimal seconds [NSEW]
334         params.merge!(dms_to_decdeg(latlon)).delete(:query)
335
336       elsif latlon = query.match(/^\s*([+-]?\d+(\.\d*)?)\s*[\s,]\s*([+-]?\d+(\.\d*)?)\s*$/)
337         params.merge!(:lat => latlon[1].to_f, :lon => latlon[3].to_f).delete(:query)
338       end
339     end
340
341     params.permit(:query, :lat, :lon, :zoom, :minlat, :minlon, :maxlat, :maxlon)
342   end
343
344   def nsew_to_decdeg(captures)
345     begin
346       Float(captures[0])
347       lat = !captures[2].casecmp("s").zero? ? captures[0].to_f : -captures[0].to_f
348       lon = !captures[5].casecmp("w").zero? ? captures[3].to_f : -captures[3].to_f
349     rescue
350       lat = !captures[0].casecmp("s").zero? ? captures[1].to_f : -captures[1].to_f
351       lon = !captures[3].casecmp("w").zero? ? captures[4].to_f : -captures[4].to_f
352     end
353     { :lat => lat, :lon => lon }
354   end
355
356   def ddm_to_decdeg(captures)
357     begin
358       Float(captures[0])
359       lat = !captures[3].casecmp("s").zero? ? captures[0].to_f + captures[1].to_f / 60 : -(captures[0].to_f + captures[1].to_f / 60)
360       lon = !captures[7].casecmp("w").zero? ? captures[4].to_f + captures[5].to_f / 60 : -(captures[4].to_f + captures[5].to_f / 60)
361     rescue
362       lat = !captures[0].casecmp("s").zero? ? captures[1].to_f + captures[2].to_f / 60 : -(captures[1].to_f + captures[2].to_f / 60)
363       lon = !captures[4].casecmp("w").zero? ? captures[5].to_f + captures[6].to_f / 60 : -(captures[5].to_f + captures[6].to_f / 60)
364     end
365     { :lat => lat, :lon => lon }
366   end
367
368   def dms_to_decdeg(captures)
369     begin
370       Float(captures[0])
371       lat = !captures[4].casecmp("s").zero? ? captures[0].to_f + (captures[1].to_f + captures[2].to_f / 60) / 60 : -(captures[0].to_f + (captures[1].to_f + captures[2].to_f / 60) / 60)
372       lon = !captures[9].casecmp("w").zero? ? captures[5].to_f + (captures[6].to_f + captures[7].to_f / 60) / 60 : -(captures[5].to_f + (captures[6].to_f + captures[7].to_f / 60) / 60)
373     rescue
374       lat = !captures[0].casecmp("s").zero? ? captures[1].to_f + (captures[2].to_f + captures[3].to_f / 60) / 60 : -(captures[1].to_f + (captures[2].to_f + captures[3].to_f / 60) / 60)
375       lon = !captures[5].casecmp("w").zero? ? captures[6].to_f + (captures[7].to_f + captures[8].to_f / 60) / 60 : -(captures[6].to_f + (captures[7].to_f + captures[8].to_f / 60) / 60)
376     end
377     { :lat => lat, :lon => lon }
378   end
379 end