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