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