Made user input parsing more robust in changeset query method. Added tests.
[rails.git] / app / controllers / changeset_controller.rb
1 # The ChangesetController is the RESTful interface to Changeset objects
2
3 class ChangesetController < ApplicationController
4   require 'xml/libxml'
5   require 'diff_reader'
6
7   before_filter :authorize, :only => [:create, :update, :delete, :upload, :include, :close]
8   before_filter :check_write_availability, :only => [:create, :update, :delete, :upload, :include]
9   before_filter :check_read_availability, :except => [:create, :update, :delete, :upload, :download, :query]
10   after_filter :compress_output
11
12   # Help methods for checking boundary sanity and area size
13   include MapBoundary
14
15   # Create a changeset from XML.
16   def create
17     if request.put?
18       cs = Changeset.from_xml(request.raw_post, true)
19
20       if cs
21         cs.user_id = @user.id
22         cs.save_with_tags!
23         render :text => cs.id.to_s, :content_type => "text/plain"
24       else
25         render :nothing => true, :status => :bad_request
26       end
27     else
28       render :nothing => true, :status => :method_not_allowed
29     end
30   end
31
32   def read
33     begin
34       changeset = Changeset.find(params[:id])
35       render :text => changeset.to_xml.to_s, :content_type => "text/xml"
36     rescue ActiveRecord::RecordNotFound
37       render :nothing => true, :status => :not_found
38     end
39   end
40   
41   ##
42   # marks a changeset as closed. this may be called multiple times
43   # on the same changeset, so is idempotent.
44   def close 
45     unless request.put?
46       render :nothing => true, :status => :method_not_allowed
47       return
48     end
49     
50     changeset = Changeset.find(params[:id])
51     
52     unless @user.id == changeset.user_id 
53       raise OSM::APIUserChangesetMismatchError 
54     end
55     
56     changeset.open = false
57     changeset.save!
58     render :nothing => true
59   rescue ActiveRecord::RecordNotFound
60     render :nothing => true, :status => :not_found
61   rescue OSM::APIError => ex
62     render ex.render_opts
63   end
64
65   ##
66   # insert a (set of) points into a changeset bounding box. this can only
67   # increase the size of the bounding box. this is a hint that clients can
68   # set either before uploading a large number of changes, or changes that
69   # the client (but not the server) knows will affect areas further away.
70   def include
71     # only allow POST requests, because although this method is
72     # idempotent, there is no "document" to PUT really...
73     if request.post?
74       cs = Changeset.find(params[:id])
75
76       # check user credentials - only the user who opened a changeset
77       # may alter it.
78       unless @user.id == cs.user_id 
79         raise OSM::APIUserChangesetMismatchError 
80       end
81
82       # keep an array of lons and lats
83       lon = Array.new
84       lat = Array.new
85
86       # the request is in pseudo-osm format... this is kind-of an
87       # abuse, maybe should change to some other format?
88       doc = XML::Parser.string(request.raw_post).parse
89       doc.find("//osm/node").each do |n|
90         lon << n['lon'].to_f * GeoRecord::SCALE
91         lat << n['lat'].to_f * GeoRecord::SCALE
92       end
93
94       # add the existing bounding box to the lon-lat array
95       lon << cs.min_lon unless cs.min_lon.nil?
96       lat << cs.min_lat unless cs.min_lat.nil?
97       lon << cs.max_lon unless cs.max_lon.nil?
98       lat << cs.max_lat unless cs.max_lat.nil?
99
100       # collapse the arrays to minimum and maximum
101       cs.min_lon, cs.min_lat, cs.max_lon, cs.max_lat = 
102         lon.min, lat.min, lon.max, lat.max
103
104       # save the larger bounding box and return the changeset, which
105       # will include the bigger bounding box.
106       cs.save!
107       render :text => cs.to_xml.to_s, :content_type => "text/xml"
108
109     else
110       render :nothing => true, :status => :method_not_allowed
111     end
112
113   rescue ActiveRecord::RecordNotFound
114     render :nothing => true, :status => :not_found
115   rescue OSM::APIError => ex
116     render ex.render_opts
117   end
118
119   ##
120   # Upload a diff in a single transaction.
121   #
122   # This means that each change within the diff must succeed, i.e: that
123   # each version number mentioned is still current. Otherwise the entire
124   # transaction *must* be rolled back.
125   #
126   # Furthermore, each element in the diff can only reference the current
127   # changeset.
128   #
129   # Returns: a diffResult document, as described in 
130   # http://wiki.openstreetmap.org/index.php/OSM_Protocol_Version_0.6
131   def upload
132     # only allow POST requests, as the upload method is most definitely
133     # not idempotent, as several uploads with placeholder IDs will have
134     # different side-effects.
135     # see http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.1.2
136     unless request.post?
137       render :nothing => true, :status => :method_not_allowed
138       return
139     end
140
141     changeset = Changeset.find(params[:id])
142
143     # access control - only the user who created a changeset may
144     # upload to it.
145     unless @user.id == changeset.user_id 
146       raise OSM::APIUserChangesetMismatchError 
147     end
148     
149     diff_reader = DiffReader.new(request.raw_post, changeset)
150     Changeset.transaction do
151       result = diff_reader.commit
152       render :text => result.to_s, :content_type => "text/xml"
153     end
154     
155   rescue ActiveRecord::RecordNotFound
156     render :nothing => true, :status => :not_found
157   rescue OSM::APIError => ex
158     render ex.render_opts
159   end
160
161   ##
162   # download the changeset as an osmChange document.
163   #
164   # to make it easier to revert diffs it would be better if the osmChange
165   # format were reversible, i.e: contained both old and new versions of 
166   # modified elements. but it doesn't at the moment...
167   #
168   # this method cannot order the database changes fully (i.e: timestamp and
169   # version number may be too coarse) so the resulting diff may not apply
170   # to a different database. however since changesets are not atomic this 
171   # behaviour cannot be guaranteed anyway and is the result of a design
172   # choice.
173   def download
174     changeset = Changeset.find(params[:id])
175     
176     # get all the elements in the changeset and stick them in a big array.
177     elements = [changeset.old_nodes, 
178                 changeset.old_ways, 
179                 changeset.old_relations].flatten
180     
181     # sort the elements by timestamp and version number, as this is the 
182     # almost sensible ordering available. this would be much nicer if 
183     # global (SVN-style) versioning were used - then that would be 
184     # unambiguous.
185     elements.sort! do |a, b| 
186       if (a.timestamp == b.timestamp)
187         a.version <=> b.version
188       else
189         a.timestamp <=> b.timestamp 
190       end
191     end
192     
193     # create an osmChange document for the output
194     result = OSM::API.new.get_xml_doc
195     result.root.name = "osmChange"
196
197     # generate an output element for each operation. note: we avoid looking
198     # at the history because it is simpler - but it would be more correct to 
199     # check these assertions.
200     elements.each do |elt|
201       result.root <<
202         if (elt.version == 1) 
203           # first version, so it must be newly-created.
204           created = XML::Node.new "create"
205           created << elt.to_xml_node
206         else
207           # get the previous version from the element history
208           prev_elt = elt.class.find(:first, :conditions => 
209                                     ['id = ? and version = ?',
210                                      elt.id, elt.version])
211           unless elt.visible
212             # if the element isn't visible then it must have been deleted, so
213             # output the *previous* XML
214             deleted = XML::Node.new "delete"
215             deleted << prev_elt.to_xml_node
216           else
217             # must be a modify, for which we don't need the previous version
218             # yet...
219             modified = XML::Node.new "modify"
220             modified << elt.to_xml_node
221           end
222         end
223     end
224
225     render :text => result.to_s, :content_type => "text/xml"
226             
227   rescue ActiveRecord::RecordNotFound
228     render :nothing => true, :status => :not_found
229   rescue OSM::APIError => ex
230     render ex.render_opts
231   end
232
233   ##
234   # query changesets by bounding box, time, user or open/closed status.
235   def query
236     # create the conditions that the user asked for. some or all of
237     # these may be nil.
238     conditions = conditions_bbox(params['bbox'])
239     conditions = cond_merge conditions, conditions_user(params['user'])
240     conditions = cond_merge conditions, conditions_time(params['time'])
241     conditions = cond_merge conditions, conditions_open(params['open'])
242
243     # create the results document
244     results = OSM::API.new.get_xml_doc
245
246     # add all matching changesets to the XML results document
247     Changeset.find(:all, 
248                    :conditions => conditions, 
249                    :limit => 100,
250                    :order => 'created_at desc').each do |cs|
251       results.root << cs.to_xml_node
252     end
253
254     render :text => results.to_s, :content_type => "text/xml"
255
256   rescue ActiveRecord::RecordNotFound
257     render :nothing => true, :status => :not_found
258   rescue OSM::APIError => ex
259     render ex.render_opts
260   end
261   
262   ##
263   # updates a changeset's tags. none of the changeset's attributes are
264   # user-modifiable, so they will be ignored.
265   #
266   # changesets are not (yet?) versioned, so we don't have to deal with
267   # history tables here. changesets are locked to a single user, however.
268   #
269   # after succesful update, returns the XML of the changeset.
270   def update
271     # request *must* be a PUT.
272     unless request.put?
273       render :nothing => true, :status => :method_not_allowed
274       return
275     end
276     
277     changeset = Changeset.find(params[:id])
278     new_changeset = Changeset.from_xml(request.raw_post)
279
280     unless new_changeset.nil?
281       changeset.update_from(new_changeset, @user)
282       render :text => changeset.to_xml, :mime_type => "text/xml"
283     else
284       
285       render :nothing => true, :status => :bad_request
286     end
287       
288   rescue ActiveRecord::RecordNotFound
289     render :nothing => true, :status => :not_found
290   rescue OSM::APIError => ex
291     render ex.render_opts
292   end
293
294   #------------------------------------------------------------
295   # utility functions below.
296   #------------------------------------------------------------  
297
298   ##
299   # merge two conditions
300   def cond_merge(a, b)
301     if a and b
302       a_str = a.shift
303       b_str = b.shift
304       return [ a_str + " and " + b_str ] + a + b
305     elsif a 
306       return a
307     else b
308       return b
309     end
310   end
311
312   ##
313   # if a bounding box was specified then parse it and do some sanity 
314   # checks. this is mostly the same as the map call, but without the 
315   # area restriction.
316   def conditions_bbox(bbox)
317     unless bbox.nil?
318       raise OSM::APIBadUserInput.new("Bounding box should be min_lon,min_lat,max_lon,max_lat") unless bbox.count(',') == 3
319       bbox = sanitise_boundaries(bbox.split(/,/))
320       raise OSM::APIBadUserInput.new("Minimum longitude should be less than maximum.") unless bbox[0] <= bbox[2]
321       raise OSM::APIBadUserInput.new("Minimum latitude should be less than maximum.") unless bbox[1] <= bbox[3]
322       return ['min_lon < ? and max_lon > ? and min_lat < ? and max_lat > ?',
323               bbox[2] * GeoRecord::SCALE, bbox[0] * GeoRecord::SCALE, bbox[3]* GeoRecord::SCALE, bbox[1] * GeoRecord::SCALE]
324     else
325       return nil
326     end
327   end
328
329   ##
330   # restrict changesets to those by a particular user
331   def conditions_user(user)
332     unless user.nil?
333       # user input checking, we don't have any UIDs < 1
334       raise OSM::APIBadUserInput.new("invalid user ID") if user.to_i < 1
335
336       u = User.find(user.to_i)
337       # should be able to get changesets of public users only, or 
338       # our own changesets regardless of public-ness.
339       unless u.data_public?
340         # get optional user auth stuff so that users can see their own
341         # changesets if they're non-public
342         setup_user_auth
343         
344         raise OSM::APINotFoundError if @user.nil? or @user.id != u.id
345       end
346       return ['user_id = ?', u.id]
347     else
348       return nil
349     end
350   end
351
352   ##
353   # restrict changes to those during a particular time period
354   def conditions_time(time) 
355     unless time.nil?
356       # if there is a range, i.e: comma separated, then the first is 
357       # low, second is high - same as with bounding boxes.
358       if time.count(',') == 1
359         # check that we actually have 2 elements in the array
360         times = time.split(/,/)
361         raise OSM::APIBadUserInput.new("bad time range") if times.size != 2 
362
363         from, to = times.collect { |t| DateTime.parse(t) }
364         return ['created_at > ? and created_at < ?', from, to]
365       else
366         # if there is no comma, assume its a lower limit on time
367         return ['created_at > ?', DateTime.parse(time)]
368       end
369     else
370       return nil
371     end
372     # stupid DateTime seems to throw both of these for bad parsing, so
373     # we have to catch both and ensure the correct code path is taken.
374   rescue ArgumentError => ex
375     raise OSM::APIBadUserInput.new(ex.message.to_s)
376   rescue RuntimeError => ex
377     raise OSM::APIBadUserInput.new(ex.message.to_s)
378   end
379
380   ##
381   # restrict changes to those which are open
382   def conditions_open(open)
383     return open.nil? ? nil : ['open = ?', true]
384   end
385
386 end