From: Tom Hughes Date: Thu, 16 Apr 2009 21:11:12 +0000 (+0000) Subject: Merge 14394:14533 from trunk. X-Git-Tag: live~7630^2~10 X-Git-Url: https://git.openstreetmap.org/rails.git/commitdiff_plain/5449cf4adcc1fad4b9f43426e6d3e4a8f65e6fbb?hp=80c6c685bcf097754e872ba61444fc4bb93f2515 Merge 14394:14533 from trunk. --- diff --git a/app/controllers/amf_controller.rb b/app/controllers/amf_controller.rb index 7f85280b7..de3c7583b 100644 --- a/app/controllers/amf_controller.rb +++ b/app/controllers/amf_controller.rb @@ -3,7 +3,7 @@ # OSM database takes place using this controller. Messages are # encoded in the Actionscript Message Format (AMF). # -# Helper functions are in /lib/potlatch. +# Helper functions are in /lib/potlatch.rb # # Author:: editions Systeme D / Richard Fairhurst 2004-2008 # Licence:: public domain. @@ -14,6 +14,12 @@ # from the AMF message), each method generally takes arguments in the order # they were sent by the Potlatch SWF. Do not assume typing has been preserved. # Methods all return an array to the SWF. +# +# == API 0.6 +# +# Note that this requires a patched version of composite_primary_keys 1.1.0 +# (see http://groups.google.com/group/compositekeys/t/a00e7562b677e193) +# if you are to run with POTLATCH_USE_SQL=false . # # == Debugging # @@ -21,13 +27,19 @@ # return(-1,"message") <-- just puts up a dialogue # return(-2,"message") <-- also asks the user to e-mail me # -# To write to the Rails log, use RAILS_DEFAULT_LOGGER.info("message"). +# To write to the Rails log, use logger.info("message"). + +# Remaining issues: +# * version conflict when POIs and ways are reverted class AmfController < ApplicationController require 'stringio' include Potlatch + # Help methods for checking boundary sanity and area size + include MapBoundary + session :off before_filter :check_api_writable @@ -36,235 +48,403 @@ class AmfController < ApplicationController # ** FIXME: refactor to reduce duplication of code across read/write def amf_read - req=StringIO.new(request.raw_post+0.chr)# Get POST data as request - # (cf http://www.ruby-forum.com/topic/122163) - req.read(2) # Skip version indicator and client ID - results={} # Results of each body - - # Parse request - - headers=AMF.getint(req) # Read number of headers - - headers.times do # Read each header - name=AMF.getstring(req) # | - req.getc # | skip boolean - value=AMF.getvalue(req) # | - header["name"]=value # | - end - - bodies=AMF.getint(req) # Read number of bodies - bodies.times do # Read each body - message=AMF.getstring(req) # | get message name - index=AMF.getstring(req) # | get index in response sequence - bytes=AMF.getlong(req) # | get total size in bytes - args=AMF.getvalue(req) # | get response (probably an array) - - case message - when 'getpresets'; results[index]=AMF.putdata(index,getpresets()) - when 'whichways'; results[index]=AMF.putdata(index,whichways(*args)) - when 'whichways_deleted'; results[index]=AMF.putdata(index,whichways_deleted(*args)) - when 'getway'; results[index]=AMF.putdata(index,getway(args[0].to_i)) - when 'getrelation'; results[index]=AMF.putdata(index,getrelation(args[0].to_i)) - when 'getway_old'; results[index]=AMF.putdata(index,getway_old(args[0].to_i,args[1].to_i)) - when 'getway_history'; results[index]=AMF.putdata(index,getway_history(args[0].to_i)) - when 'getnode_history'; results[index]=AMF.putdata(index,getnode_history(args[0].to_i)) - when 'findrelations'; results[index]=AMF.putdata(index,findrelations(*args)) - when 'getpoi'; results[index]=AMF.putdata(index,getpoi(*args)) - end - end + req=StringIO.new(request.raw_post+0.chr)# Get POST data as request + # (cf http://www.ruby-forum.com/topic/122163) + req.read(2) # Skip version indicator and client ID + results={} # Results of each body + + # Parse request + + headers=AMF.getint(req) # Read number of headers + + headers.times do # Read each header + name=AMF.getstring(req) # | + req.getc # | skip boolean + value=AMF.getvalue(req) # | + header["name"]=value # | + end + + bodies=AMF.getint(req) # Read number of bodies + bodies.times do # Read each body + message=AMF.getstring(req) # | get message name + index=AMF.getstring(req) # | get index in response sequence + bytes=AMF.getlong(req) # | get total size in bytes + args=AMF.getvalue(req) # | get response (probably an array) + logger.info("Executing AMF #{message}:#{index}") + + case message + when 'getpresets'; results[index]=AMF.putdata(index,getpresets()) + when 'whichways'; results[index]=AMF.putdata(index,whichways(*args)) + when 'whichways_deleted'; results[index]=AMF.putdata(index,whichways_deleted(*args)) + when 'getway'; results[index]=AMF.putdata(index,getway(args[0].to_i)) + when 'getrelation'; results[index]=AMF.putdata(index,getrelation(args[0].to_i)) + when 'getway_old'; results[index]=AMF.putdata(index,getway_old(args[0].to_i,args[1])) + when 'getway_history'; results[index]=AMF.putdata(index,getway_history(args[0].to_i)) + when 'getnode_history'; results[index]=AMF.putdata(index,getnode_history(args[0].to_i)) + when 'findgpx'; results[index]=AMF.putdata(index,findgpx(*args)) + when 'findrelations'; results[index]=AMF.putdata(index,findrelations(*args)) + when 'getpoi'; results[index]=AMF.putdata(index,getpoi(*args)) + end + end + logger.info("encoding AMF results") sendresponse(results) end def amf_write - req=StringIO.new(request.raw_post+0.chr) - req.read(2) - results={} - renumberednodes={} # Shared across repeated putways - renumberedways={} # Shared across repeated putways - - headers=AMF.getint(req) # Read number of headers - headers.times do # Read each header - name=AMF.getstring(req) # | - req.getc # | skip boolean - value=AMF.getvalue(req) # | - header["name"]=value # | - end - - bodies=AMF.getint(req) # Read number of bodies - bodies.times do # Read each body - message=AMF.getstring(req) # | get message name - index=AMF.getstring(req) # | get index in response sequence - bytes=AMF.getlong(req) # | get total size in bytes - args=AMF.getvalue(req) # | get response (probably an array) - - case message - when 'putway'; r=putway(renumberednodes,*args) + req=StringIO.new(request.raw_post+0.chr) + req.read(2) + results={} + renumberednodes={} # Shared across repeated putways + renumberedways={} # Shared across repeated putways + + headers=AMF.getint(req) # Read number of headers + headers.times do # Read each header + name=AMF.getstring(req) # | + req.getc # | skip boolean + value=AMF.getvalue(req) # | + header["name"]=value # | + end + + bodies=AMF.getint(req) # Read number of bodies + bodies.times do # Read each body + message=AMF.getstring(req) # | get message name + index=AMF.getstring(req) # | get index in response sequence + bytes=AMF.getlong(req) # | get total size in bytes + args=AMF.getvalue(req) # | get response (probably an array) + + logger.info("Executing AMF #{message}:#{index}") + case message + when 'putway'; r=putway(renumberednodes,*args) renumberednodes=r[3] - if r[1] != r[2] - renumberedways[r[1]] = r[2] - end + if r[1] != r[2] then renumberedways[r[1]] = r[2] end results[index]=AMF.putdata(index,r) - when 'putrelation'; results[index]=AMF.putdata(index,putrelation(renumberednodes, renumberedways, *args)) - when 'deleteway'; results[index]=AMF.putdata(index,deleteway(args[0],args[1].to_i)) - when 'putpoi'; results[index]=AMF.putdata(index,putpoi(*args)) - end - end + when 'putrelation'; results[index]=AMF.putdata(index,putrelation(renumberednodes, renumberedways, *args)) + when 'deleteway'; results[index]=AMF.putdata(index,deleteway(*args)) + when 'putpoi'; r=putpoi(*args) + if r[1] != r[2] then renumberednodes[r[1]] = r[2] end + results[index]=AMF.putdata(index,r) + when 'startchangeset'; results[index]=AMF.putdata(index,startchangeset(*args)) + end + end + logger.info("encoding AMF results") sendresponse(results) end private + # Start new changeset + + def startchangeset(usertoken, cstags, closeid, closecomment) + user = getuser(usertoken) + if !user then return -1,"You are not logged in, so Potlatch can't write any changes to the database." end + + # close previous changeset and add comment + if closeid + cs = Changeset.find(closeid) + cs.set_closed_time_now + if cs.user_id!=user.id + return -2,"You cannot close that changeset because you're not the person who opened it." + elsif closecomment.empty? + cs.save! + else + cs.tags['comment']=closecomment + cs.save_with_tags! + end + end + + # open a new changeset + cs = Changeset.new + cs.tags = cstags + cs.user_id = user.id + # smsm1 doesn't like the next two lines and thinks they need to be abstracted to the model more/better + cs.created_at = Time.now.getutc + cs.closed_at = cs.created_at + Changeset::IDLE_TIMEOUT + cs.save_with_tags! + return [0,cs.id] + end + # Return presets (default tags, localisation etc.): # uses POTLATCH_PRESETS global, set up in OSM::Potlatch. def getpresets() #:doc: - return POTLATCH_PRESETS + return POTLATCH_PRESETS end + ## # Find all the ways, POI nodes (i.e. not part of ways), and relations # in a given bounding box. Nodes are returned in full; ways and relations # are IDs only. - + # + # return is of the form: + # [error_code, + # [[way_id, way_version], ...], + # [[node_id, lat, lon, [tags, ...], node_version], ...], + # [[rel_id, rel_version], ...]] + # where the ways are any visible ways which refer to any visible + # nodes in the bbox, nodes are any visible nodes in the bbox but not + # used in any way, rel is any relation which refers to either a way + # or node that we're returning. def whichways(xmin, ymin, xmax, ymax) #:doc: - enlarge = [(xmax-xmin)/8,0.01].min - xmin -= enlarge; ymin -= enlarge - xmax += enlarge; ymax += enlarge - - if POTLATCH_USE_SQL then - way_ids = sql_find_way_ids_in_area(xmin, ymin, xmax, ymax) - points = sql_find_pois_in_area(xmin, ymin, xmax, ymax) - relation_ids = sql_find_relations_in_area_and_ways(xmin, ymin, xmax, ymax, way_ids) - else - # find the way ids in an area - nodes_in_area = Node.find_by_area(ymin, xmin, ymax, xmax, :conditions => "current_nodes.visible = 1", :include => :ways) - way_ids = nodes_in_area.collect { |node| node.way_ids }.flatten.uniq - - # find the node ids in an area that aren't part of ways - nodes_not_used_in_area = nodes_in_area.select { |node| node.ways.empty? } - points = nodes_not_used_in_area.collect { |n| [n.id, n.lon, n.lat, n.tags_as_hash] } - - # find the relations used by those nodes and ways - relations = Relation.find_for_nodes(nodes_in_area.collect { |n| n.id }, :conditions => "visible = 1") + - Relation.find_for_ways(way_ids, :conditions => "visible = 1") - relation_ids = relations.collect { |relation| relation.id }.uniq - end - - [way_ids, points, relation_ids] + enlarge = [(xmax-xmin)/8,0.01].min + xmin -= enlarge; ymin -= enlarge + xmax += enlarge; ymax += enlarge + + # check boundary is sane and area within defined + # see /config/application.yml + check_boundaries(xmin, ymin, xmax, ymax) + + if POTLATCH_USE_SQL then + ways = sql_find_ways_in_area(xmin, ymin, xmax, ymax) + points = sql_find_pois_in_area(xmin, ymin, xmax, ymax) + relations = sql_find_relations_in_area_and_ways(xmin, ymin, xmax, ymax, ways.collect {|x| x[0]}) + else + # find the way ids in an area + nodes_in_area = Node.find_by_area(ymin, xmin, ymax, xmax, :conditions => ["current_nodes.visible = ?", true], :include => :ways) + ways = nodes_in_area.inject([]) { |sum, node| + visible_ways = node.ways.select { |w| w.visible? } + sum + visible_ways.collect { |w| [w.id,w.version] } + }.uniq + ways.delete([]) + + # find the node ids in an area that aren't part of ways + nodes_not_used_in_area = nodes_in_area.select { |node| node.ways.empty? } + points = nodes_not_used_in_area.collect { |n| [n.id, n.lon, n.lat, n.tags, n.version] }.uniq + + # find the relations used by those nodes and ways + relations = Relation.find_for_nodes(nodes_in_area.collect { |n| n.id }, :conditions => {:visible => true}) + + Relation.find_for_ways(ways.collect { |w| w[0] }, :conditions => {:visible => true}) + relations = relations.collect { |relation| [relation.id,relation.version] }.uniq + end + + [0, ways, points, relations] + + rescue Exception => err + [-2,"Sorry - I can't get the map for that area."] end # Find deleted ways in current bounding box (similar to whichways, but ways # with a deleted node only - not POIs or relations). def whichways_deleted(xmin, ymin, xmax, ymax) #:doc: - xmin -= 0.01; ymin -= 0.01 - xmax += 0.01; ymax += 0.01 - - nodes_in_area = Node.find_by_area(ymin, xmin, ymax, xmax, :conditions => "current_nodes.visible = 0 AND current_ways.visible = 0", :include => :ways_via_history) - way_ids = nodes_in_area.collect { |node| node.ways_via_history_ids }.flatten.uniq - - [way_ids] + enlarge = [(xmax-xmin)/8,0.01].min + xmin -= enlarge; ymin -= enlarge + xmax += enlarge; ymax += enlarge + + # check boundary is sane and area within defined + # see /config/application.yml + begin + check_boundaries(xmin, ymin, xmax, ymax) + rescue Exception => err + return [-2,"Sorry - I can't get the map for that area."] + end + + nodes_in_area = Node.find_by_area(ymin, xmin, ymax, xmax, :conditions => ["current_ways.visible = ?", false], :include => :ways_via_history) + way_ids = nodes_in_area.collect { |node| node.ways_via_history_ids }.flatten.uniq + + [0,way_ids] end # Get a way including nodes and tags. - # Returns 0 (success), a Potlatch-style array of points, and a hash of tags. + # Returns the way id, a Potlatch-style array of points, a hash of tags, and the version number. def getway(wayid) #:doc: - if POTLATCH_USE_SQL then - points = sql_get_nodes_in_way(wayid) - tags = sql_get_tags_in_way(wayid) - else - # Ideally we would do ":include => :nodes" here but if we do that - # then rails only seems to return the first copy of a node when a - # way includes a node more than once - way = Way.find(wayid) - points = way.nodes.collect do |node| - nodetags=node.tags_as_hash - nodetags.delete('created_by') - [node.lon, node.lat, node.id, nodetags] - end - tags = way.tags - end - - [wayid, points, tags] + if POTLATCH_USE_SQL then + points = sql_get_nodes_in_way(wayid) + tags = sql_get_tags_in_way(wayid) + version = sql_get_way_version(wayid) + else + # Ideally we would do ":include => :nodes" here but if we do that + # then rails only seems to return the first copy of a node when a + # way includes a node more than once + begin + way = Way.find(wayid) + rescue ActiveRecord::RecordNotFound + return [wayid,[],{}] + end + + # check case where way has been deleted or doesn't exist + return [wayid,[],{}] if way.nil? or !way.visible + + points = way.nodes.collect do |node| + nodetags=node.tags + nodetags.delete('created_by') + [node.lon, node.lat, node.id, nodetags, node.version] + end + tags = way.tags + version = way.version + end + + [wayid, points, tags, version] end # Get an old version of a way, and all constituent nodes. # - # For undelete (version=0), always uses the most recent version of each node, - # even if it's moved. For revert (version=1+), uses the node in existence + # For undelete (version<0), always uses the most recent version of each node, + # even if it's moved. For revert (version >= 0), uses the node in existence # at the time, generating a new id if it's still visible and has been moved/ # retagged. - - def getway_old(id, version) #:doc: - if version < 0 - old_way = OldWay.find(:first, :conditions => ['visible = 1 AND id = ?', id], :order => 'version DESC') - points = old_way.get_nodes_undelete - else - old_way = OldWay.find(:first, :conditions => ['id = ? AND version = ?', id, version]) - points = old_way.get_nodes_revert - end - - old_way.tags['history'] = "Retrieved from v#{old_way.version}" - - [0, id, points, old_way.tags, old_way.version] + # + # Returns: + # 0. success code, + # 1. id, + # 2. array of points, + # 3. hash of tags, + # 4. version, + # 5. is this the current, visible version? (boolean) + + def getway_old(id, timestamp) #:doc: + if timestamp == '' + # undelete + old_way = OldWay.find(:first, :conditions => ['visible = ? AND id = ?', true, id], :order => 'version DESC') + points = old_way.get_nodes_undelete unless old_way.nil? + else + begin + # revert + timestamp = DateTime.strptime(timestamp.to_s, "%d %b %Y, %H:%M:%S") + old_way = OldWay.find(:first, :conditions => ['id = ? AND timestamp <= ?', id, timestamp], :order => 'timestamp DESC') + unless old_way.nil? + points = old_way.get_nodes_revert(timestamp) + if !old_way.visible + return [-1, "Sorry, the way was deleted at that time - please revert to a previous version."] + end + end + rescue ArgumentError + # thrown by date parsing method. leave old_way as nil for + # the superb error handler below. + end + end + + if old_way.nil? + # *** FIXME: shouldn't this be returning an error? + return [-1, id, [], {}, -1,0] + else + curway=Way.find(id) + old_way.tags['history'] = "Retrieved from v#{old_way.version}" + return [0, id, points, old_way.tags, curway.version, (curway.version==old_way.version and curway.visible)] + end end - # Find history of a way. Returns 'way', id, and - # an array of previous versions. + # Find history of a way. + # Returns 'way', id, and an array of previous versions: + # - formerly [old_way.version, old_way.timestamp.strftime("%d %b %Y, %H:%M"), old_way.visible ? 1 : 0, user, uid] + # - now [timestamp,user,uid] + # + # Heuristic: Find all nodes that have ever been part of the way; + # get a list of their revision dates; add revision dates of the way; + # sort and collapse list (to within 2 seconds); trim all dates before the + # start date of the way. def getway_history(wayid) #:doc: - history = Way.find(wayid).old_ways.reverse.collect do |old_way| - user = old_way.user.data_public? ? old_way.user.display_name : 'anonymous' - uid = old_way.user.data_public? ? old_way.user.id : 0 - [old_way.version, old_way.timestamp.strftime("%d %b %Y, %H:%M"), old_way.visible ? 1 : 0, user, uid] - end - ['way',wayid,history] + begin + # Find list of revision dates for way and all constituent nodes + revdates=[] + revusers={} + Way.find(wayid).old_ways.collect do |a| + revdates.push(a.timestamp) + unless revusers.has_key?(a.timestamp.to_i) then revusers[a.timestamp.to_i]=change_user(a) end + a.nds.each do |n| + Node.find(n).old_nodes.collect do |o| + revdates.push(o.timestamp) + unless revusers.has_key?(o.timestamp.to_i) then revusers[o.timestamp.to_i]=change_user(o) end + end + end + end + waycreated=revdates[0] + revdates.uniq! + revdates.sort! + revdates.reverse! + + # Remove any dates (from nodes) before first revision date of way + revdates.delete_if { |d| d0 then + gpx = Trace.find(searchterm.to_i, :conditions => ["visible=? AND (public=? OR user_id=?)",true,true,user.id] ) + if gpx then + gpxs.push([gpx.id, gpx.name, gpx.description]) + end + else + Trace.find(:all, :limit => 21, :conditions => ["visible=? AND (public=? OR user_id=?) AND MATCH(name) AGAINST (?)",true,true,user.id,searchterm] ).each do |gpx| + gpxs.push([gpx.id, gpx.name, gpx.description]) + end + end + gpxs end # Get a relation with all tags and members. # Returns: # 0. relation id, # 1. hash of tags, - # 2. list of members. + # 2. list of members, + # 3. version. def getrelation(relid) #:doc: - rel = Relation.find(relid) - - [relid, rel.tags, rel.members] + begin + rel = Relation.find(relid) + rescue ActiveRecord::RecordNotFound + return [relid, {}, []] + end + + return [relid, {}, [], nil] if rel.nil? or !rel.visible + [relid, rel.tags, rel.members, rel.version] end # Find relations with specified name/id. # Returns array of relations, each in same form as getrelation. def findrelations(searchterm) - rels = [] - if searchterm.to_i>0 then - rel = Relation.find(searchterm.to_i) - if rel and rel.visible then - rels.push([rel.id, rel.tags, rel.members]) - end - else - RelationTag.find(:all, :limit => 11, :conditions => ["match(v) against (?)", searchterm] ).each do |t| - if t.relation.visible then + rels = [] + if searchterm.to_i>0 then + rel = Relation.find(searchterm.to_i) + if rel and rel.visible then + rels.push([rel.id, rel.tags, rel.members]) + end + else + RelationTag.find(:all, :limit => 11, :conditions => ["match(v) against (?)", searchterm] ).each do |t| + if t.relation.visible then rels.push([t.relation.id, t.relation.tags, t.relation.members]) end end end - rels + rels end # Save a relation. @@ -273,226 +453,335 @@ class AmfController < ApplicationController # 1. original relation id (unchanged), # 2. new relation id. - def putrelation(renumberednodes, renumberedways, usertoken, relid, tags, members, visible) #:doc: - uid = getuserid(usertoken) - if !uid then return -1,"You are not logged in, so the relation could not be saved." end - - relid = relid.to_i - visible = visible.to_i - - # create a new relation, or find the existing one - if relid <= 0 - rel = Relation.new - else - rel = Relation.find(relid) - end - - # check the members are all positive, and correctly type - typedmembers = [] - members.each do |m| - mid = m[1].to_i - if mid < 0 - mid = renumberednodes[mid] if m[0] == 'node' - mid = renumberedways[mid] if m[0] == 'way' - end - if mid - typedmembers << [m[0], mid, m[2]] - end - end - - # assign new contents - rel.members = typedmembers - rel.tags = tags - rel.visible = visible - rel.user_id = uid - - # check it then save it - # BUG: the following is commented out because it always fails on my - # install. I think it's a Rails bug. - - #if !rel.preconditions_ok? - # return -2, "Relation preconditions failed" - #else - rel.save_with_history! - #end - - [0, relid, rel.id] + def putrelation(renumberednodes, renumberedways, usertoken, changeset_id, version, relid, tags, members, visible) #:doc: + user = getuser(usertoken) + if !user then return -1,"You are not logged in, so the relation could not be saved." end + + relid = relid.to_i + visible = (visible.to_i != 0) + + new_relation = nil + relation = nil + Relation.transaction do + # create a new relation, or find the existing one + if relid > 0 + relation = Relation.find(relid) + end + # We always need a new node, based on the data that has been sent to us + new_relation = Relation.new + + # check the members are all positive, and correctly type + typedmembers = [] + members.each do |m| + mid = m[1].to_i + if mid < 0 + mid = renumberednodes[mid] if m[0] == 'node' + mid = renumberedways[mid] if m[0] == 'way' + end + if mid + typedmembers << [m[0], mid, m[2]] + end + end + + # assign new contents + new_relation.members = typedmembers + new_relation.tags = tags + new_relation.visible = visible + new_relation.changeset_id = changeset_id + new_relation.version = version + + # NOTE: id or relid here? id doesn't seem to be set above + if relid <= 0 + # We're creating the node + new_relation.create_with_history(user) + elsif visible + # We're updating the node + relation.update_from(new_relation, user) + else + # We're deleting the node + relation.delete_with_history!(new_relation, user) + end + end # transaction + + if id <= 0 + return [0, relid, new_relation.id, new_relation.version] + else + return [0, relid, relation.id, relation.version] + end + rescue OSM::APIChangesetAlreadyClosedError => ex + return [-1, "The changeset #{ex.changeset.id} was closed at #{ex.changeset.closed_at}."] + rescue OSM::APIVersionMismatchError => ex + # Really need to check to see whether this is a server load issue, and the + # last version was in the same changeset, or belongs to the same user, then + # we can return something different + return [-3, "Sorry, someone else has changed this relation since you started editing. Please click the 'Edit' tab to reload the area."] + rescue OSM::APIAlreadyDeletedError => ex + return [-1, "The relation has already been deleted."] + rescue OSM::APIError => ex + # Some error that we don't specifically catch + return [-2, "Something really bad happened :-( ."] end # Save a way to the database, including all nodes. Any nodes in the previous # version and no longer used are deleted. # + # Parameters: + # 0. hash of renumbered nodes (added by amf_controller) + # 1. current user token (for authentication) + # 2. current changeset + # 3. new way version + # 4. way ID + # 5. list of nodes in way + # 6. hash of way tags + # 7. array of nodes to change (each one is [lon,lat,id,version,tags]) + # # Returns: # 0. '0' (code for success), # 1. original way id (unchanged), # 2. new way id, - # 3. hash of renumbered nodes (old id=>new id) + # 3. hash of renumbered nodes (old id=>new id), + # 4. way version, + # 5. hash of node versions (node=>version) - def putway(renumberednodes, usertoken, originalway, points, attributes) #:doc: + def putway(renumberednodes, usertoken, changeset_id, wayversion, originalway, pointlist, attributes, nodes) #:doc: - # -- Initialise and carry out checks + # -- Initialise - uid = getuserid(usertoken) - if !uid then return -1,"You are not logged in, so the way could not be saved." end - - originalway = originalway.to_i - - points.each do |a| - if a[2] == 0 or a[2].nil? then return -2,"Server error - node with id 0 found in way #{originalway}." end - if a[1] == 90 then return -2,"Server error - node with lat -90 found in way #{originalway}." end - end - - if points.length < 2 then return -2,"Server error - way is only #{points.length} points long." end - - # -- Get unique nodes - - if originalway < 0 - way = Way.new - uniques = [] - else - way = Way.find(originalway) - uniques = way.unshared_node_ids - end - - # -- Compare nodes and save changes to any that have changed - - nodes = [] - - points.each do |n| - lon = n[0].to_f - lat = n[1].to_f - id = n[2].to_i - savenode = false - - if renumberednodes[id] - id = renumberednodes[id] - elsif id < 0 - # Create new node - node = Node.new - savenode = true - else - node = Node.find(id) - nodetags=node.tags_as_hash - nodetags.delete('created_by') - if !fpcomp(lat, node.lat) or !fpcomp(lon, node.lon) or - n[4] != nodetags or !node.visible? - savenode = true - end - end - - if savenode - node.user_id = uid - node.lat = lat + user = getuser(usertoken) + if !user then return -1,"You are not logged in, so the way could not be saved." end + if pointlist.length < 2 then return -2,"Server error - way is only #{points.length} points long." end + + originalway = originalway.to_i + pointlist.collect! {|a| a.to_i } + + way=nil # this is returned, so scope it outside the transaction + nodeversions = {} + Way.transaction do + + # -- Get unique nodes + + if originalway <= 0 + uniques = [] + else + way = Way.find(originalway) + uniques = way.unshared_node_ids + end + + #-- Update each changed node + + nodes.each do |a| + lon = a[0].to_f + lat = a[1].to_f + id = a[2].to_i + version = a[3].to_i + if id == 0 then return -2,"Server error - node with id 0 found in way #{originalway}." end + if lat== 90 then return -2,"Server error - node with latitude -90 found in way #{originalway}." end + if renumberednodes[id] then id = renumberednodes[id] end + + node = Node.new + node.changeset_id = changeset_id + node.lat = lat node.lon = lon - node.tags = Tags.join(n[4]) - node.visible = true - node.save_with_history! - - if id != node.id - renumberednodes[id] = node.id - id = node.id - end - end - - uniques = uniques - [id] - nodes.push(id) - end - - # -- Save revised way - - way.tags = attributes - way.nds = nodes - way.user_id = uid - way.visible = true - way.save_with_history! - - # -- Delete any unique nodes - - uniques.each do |n| - deleteitemrelations(n, 'node') - - node = Node.find(n) - node.user_id = uid - node.visible = false - node.save_with_history! - end - - [0, originalway, way.id, renumberednodes] + node.tags = a[4] + node.tags.delete('created_by') + node.version = version + if id <= 0 + # We're creating the node + node.create_with_history(user) + renumberednodes[id] = node.id + nodeversions[node.id] = node.version + else + # We're updating an existing node + previous=Node.find(id) + previous.update_from(node, user) + nodeversions[previous.id] = previous.version + end + end + + # -- Save revised way + + pointlist.collect! {|a| + renumberednodes[a] ? renumberednodes[a]:a + } # renumber nodes + new_way = Way.new + new_way.tags = attributes + new_way.nds = pointlist + new_way.changeset_id = changeset_id + new_way.version = wayversion + if originalway <= 0 + new_way.create_with_history(user) + way=new_way # so we can get way.id and way.version + elsif way.tags!=attributes or way.nds!=pointlist or !way.visible? + way.update_from(new_way, user) + end + + # -- Delete any unique nodes no longer used + + uniques=uniques-pointlist + uniques.each do |n| + node = Node.find(n) + deleteitemrelations(user, changeset_id, id, 'node', node.version) + new_node = Node.new + new_node.changeset_id = changeset_id + new_node.version = node.version + node.delete_with_history!(new_node, user) + end + + end # transaction + + [0, originalway, way.id, renumberednodes, way.version, nodeversions] + rescue OSM::APIChangesetAlreadyClosedError => ex + return [-2, "Sorry, your changeset #{ex.changeset.id} has been closed (at #{ex.changeset.closed_at})."] + rescue OSM::APIVersionMismatchError => ex + # Really need to check to see whether this is a server load issue, and the + # last version was in the same changeset, or belongs to the same user, then + # we can return something different + return [-3, "Sorry, someone else has changed this way since you started editing. Please click the 'Edit' tab to reload the area."] + rescue OSM::APITooManyWayNodesError => ex + return [-1, "You have tried to upload a really long way with #{ex.provided} points: only #{ex.max} are allowed."] + rescue OSM::APIAlreadyDeletedError => ex + return [-1, "The point has already been deleted."] + rescue OSM::APIError => ex + # Some error that we don't specifically catch + return [-2, "Something really bad happened :-(."] end # Save POI to the database. # Refuses save if the node has since become part of a way. - # Returns: + # Returns array with: # 0. 0 (success), # 1. original node id (unchanged), - # 2. new node id. - - def putpoi(usertoken, id, lon, lat, tags, visible) #:doc: - uid = getuserid(usertoken) - if !uid then return -1,"You are not logged in, so the point could not be saved." end - - id = id.to_i - visible = (visible.to_i == 1) - - if id > 0 then - node = Node.find(id) - - if !visible then - unless node.ways.empty? then return -1,"The point has since become part of a way, so you cannot save it as a POI." end - deleteitemrelations(id, 'node') - end - else - node = Node.new - end - - node.user_id = uid - node.lat = lat - node.lon = lon - node.tags = Tags.join(tags) - node.visible = visible - node.save_with_history! - - [0, id, node.id] + # 2. new node id, + # 3. version. + + def putpoi(usertoken, changeset_id, version, id, lon, lat, tags, visible) #:doc: + user = getuser(usertoken) + if !user then return -1,"You are not logged in, so the point could not be saved." end + + id = id.to_i + visible = (visible.to_i == 1) + node = nil + new_node = nil + Node.transaction do + if id > 0 then + node = Node.find(id) + + if !visible then + unless node.ways.empty? then return -1,"The point has since become part of a way, so you cannot save it as a POI." end + end + end + # We always need a new node, based on the data that has been sent to us + new_node = Node.new + + new_node.changeset_id = changeset_id + new_node.version = version + new_node.lat = lat + new_node.lon = lon + new_node.tags = tags + if id <= 0 + # We're creating the node + new_node.create_with_history(user) + elsif visible + # We're updating the node + node.update_from(new_node, user) + else + # We're deleting the node + node.delete_with_history!(new_node, user) + end + end # transaction + + if id <= 0 + return [0, id, new_node.id, new_node.version] + else + return [0, id, node.id, node.version] + end + rescue OSM::APIChangesetAlreadyClosedError => ex + return [-1, "The changeset #{ex.changeset.id} was closed at #{ex.changeset.closed_at}"] + rescue OSM::APIVersionMismatchError => ex + # Really need to check to see whether this is a server load issue, and the + # last version was in the same changeset, or belongs to the same user, then + # we can return something different + return [-3, "Sorry, someone else has changed this point since you started editing. Please click the 'Edit' tab to reload the area."] + rescue OSM::APIAlreadyDeletedError => ex + return [-1, "The point has already been deleted"] + rescue OSM::APIError => ex + # Some error that we don't specifically catch + return [-2, "Something really bad happened :-()"] end # Read POI from database # (only called on revert: POIs are usually read by whichways). # - # Returns array of id, long, lat, hash of tags. + # Returns array of id, long, lat, hash of tags, version. def getpoi(id,timestamp) #:doc: - if timestamp>0 then - n = OldNode.find(id, :conditions=>['UNIX_TIMESTAMP(timestamp)=?',timestamp]) - else - n = Node.find(id) - end - - if n - return [n.id, n.lon, n.lat, n.tags_as_hash] - else - return [nil, nil, nil, ''] - end + if timestamp == '' then + n = Node.find(id) + else + n = OldNode.find(id, :conditions=>['timestamp=?',DateTime.strptime(timestamp, "%d %b %Y, %H:%M:%S")]) + end + + if n + return [n.id, n.lon, n.lat, n.tags, n.version] + else + return [nil, nil, nil, {}, nil] + end end # Delete way and all constituent nodes. Also removes from any relations. + # Params: + # * The user token + # * the changeset id + # * the id of the way to change + # * the version of the way that was downloaded + # * a hash of the id and versions of all the nodes that are in the way, if any + # of the nodes have been changed by someone else then, there is a problem! # Returns 0 (success), unchanged way id. - def deleteway(usertoken, way_id) #:doc: - uid = getuserid(usertoken) - if !uid then return -1,"You are not logged in, so the way could not be deleted." end - - # FIXME: would be good not to make two history entries when removing - # two nodes from the same relation - user = User.find(uid) - way = Way.find(way_id) - way.unshared_node_ids.each do |n| - deleteitemrelations(n, 'node') - end - deleteitemrelations(way_id, 'way') - - way.delete_with_relations_and_nodes_and_history(user) - - [0, way_id] + def deleteway(usertoken, changeset_id, way_id, way_version, node_id_version) #:doc: + user = getuser(usertoken) + unless user then return -1,"You are not logged in, so the way could not be deleted." end + + way_id = way_id.to_i + # Need a transaction so that if one item fails to delete, the whole delete fails. + Way.transaction do + + # delete the way + old_way = Way.find(way_id) + delete_way = Way.new + delete_way.version = way_version + delete_way.changeset_id = changeset_id + old_way.delete_with_history!(delete_way, user) + + old_way.unshared_node_ids.each do |node_id| + # delete the node + node = Node.find(node_id) + delete_node = Node.new + delete_node.changeset_id = changeset_id + if node_id_version[node_id.to_s] + delete_node.version = node_id_version[node_id.to_s] + else + # in case the node wasn't passed (i.e. if it was previously removed + # from the way in Potlatch) + deleteitemrelations(user, changeset_id, node_id, 'node', node.version) + delete_node.version = node.version + end + node.delete_with_history!(delete_node, user) + end + end # transaction + [0, way_id] + rescue OSM::APIChangesetAlreadyClosedError => ex + return [-1, "The changeset #{ex.changeset.id} was closed at #{ex.changeset.closed_at}"] + rescue OSM::APIVersionMismatchError => ex + # Really need to check to see whether this is a server load issue, and the + # last version was in the same changeset, or belongs to the same user, then + # we can return something different + return [-3, "Sorry, someone else has changed this way since you started editing. Please click the 'Edit' tab to reload the area."] + rescue OSM::APIAlreadyDeletedError => ex + return [-1, "The way has already been deleted."] + rescue OSM::APIError => ex + # Some error that we don't specifically catch + return [-2, "Something really bad happened :-( ."] end @@ -500,141 +789,147 @@ class AmfController < ApplicationController # Support functions # Remove a node or way from all relations + # This is only used by putway and deleteway when deleting nodes removed + # from a way (because Potlatch itself doesn't keep track of these - + # possible FIXME). - def deleteitemrelations(objid, type) #:doc: - relations = RelationMember.find(:all, + def deleteitemrelations(user, changeset_id, objid, type, version) #:doc: + relations = RelationMember.find(:all, :conditions => ['member_type = ? and member_id = ?', type, objid], :include => :relation).collect { |rm| rm.relation }.uniq - relations.each do |rel| - rel.members.delete_if { |x| x[0] == type and x[1] == objid } - rel.save_with_history! - end - end - - # Break out node tags into a hash - # (should become obsolete as of API 0.6) - - def tagstring_to_hash(a) #:doc: - tags={} - Tags.split(a) do |k, v| - tags[k]=v - end - tags + relations.each do |rel| + rel.members.delete_if { |x| x[0] == type and x[1] == objid } + new_rel = Relation.new + new_rel.tags = rel.tags + new_rel.visible = rel.visible + new_rel.version = rel.version + new_rel.members = rel.members + new_rel.changeset_id = changeset_id + rel.update_from(new_rel, user) + end end # Authenticate token # (can also be of form user:pass) - - def getuserid(token) #:doc: - if (token =~ /^(.+)\:(.+)$/) then - user = User.authenticate(:username => $1, :password => $2) - else - user = User.authenticate(:token => token) - end - - return user ? user.id : nil; - end - - # Compare two floating-point numbers to within 0.0000001 - - def fpcomp(a,b) #:doc: - return ((a/0.0000001).round==(b/0.0000001).round) + # When we are writing to the api, we need the actual user model, + # not just the id, hence this abstraction + + def getuser(token) #:doc: + if (token =~ /^(.+)\:(.+)$/) then + user = User.authenticate(:username => $1, :password => $2) + else + user = User.authenticate(:token => token) + end + return user end # Send AMF response def sendresponse(results) - a,b=results.length.divmod(256) - render :content_type => "application/x-amf", :text => proc { |response, output| - output.write 0.chr+0.chr+0.chr+0.chr+a.chr+b.chr - results.each do |k,v| - output.write(v) - end - } + a,b=results.length.divmod(256) + render :content_type => "application/x-amf", :text => proc { |response, output| + # ** move amf writing loop into here - + # basically we read the messages in first (into an array of some sort), + # then iterate through that array within here, and do all the AMF writing + output.write 0.chr+0.chr+0.chr+0.chr+a.chr+b.chr + results.each do |k,v| + output.write(v) + end + } end # ==================================================================== # Alternative SQL queries for getway/whichways - def sql_find_way_ids_in_area(xmin,ymin,xmax,ymax) - sql=<<-EOF - SELECT DISTINCT current_way_nodes.id AS wayid - FROM current_way_nodes - INNER JOIN current_nodes ON current_nodes.id=current_way_nodes.node_id - INNER JOIN current_ways ON current_ways.id =current_way_nodes.id - WHERE current_nodes.visible=1 - AND current_ways.visible=1 - AND #{OSM.sql_for_area(ymin, xmin, ymax, xmax, "current_nodes.")} - EOF - return ActiveRecord::Base.connection.select_all(sql).collect { |a| a['wayid'].to_i } + def sql_find_ways_in_area(xmin,ymin,xmax,ymax) + sql=<<-EOF + SELECT DISTINCT current_ways.id AS wayid,current_ways.version AS version + FROM current_way_nodes + INNER JOIN current_nodes ON current_nodes.id=current_way_nodes.node_id + INNER JOIN current_ways ON current_ways.id =current_way_nodes.id + WHERE current_nodes.visible=TRUE + AND current_ways.visible=TRUE + AND #{OSM.sql_for_area(ymin, xmin, ymax, xmax, "current_nodes.")} + EOF + return ActiveRecord::Base.connection.select_all(sql).collect { |a| [a['wayid'].to_i,a['version'].to_i] } end def sql_find_pois_in_area(xmin,ymin,xmax,ymax) - sql=<<-EOF - SELECT current_nodes.id,current_nodes.latitude*0.0000001 AS lat,current_nodes.longitude*0.0000001 AS lon,current_nodes.tags + pois=[] + sql=<<-EOF + SELECT current_nodes.id,current_nodes.latitude*0.0000001 AS lat,current_nodes.longitude*0.0000001 AS lon,current_nodes.version FROM current_nodes - LEFT OUTER JOIN current_way_nodes cwn ON cwn.node_id=current_nodes.id - WHERE current_nodes.visible=1 + LEFT OUTER JOIN current_way_nodes cwn ON cwn.node_id=current_nodes.id + WHERE current_nodes.visible=TRUE AND cwn.id IS NULL AND #{OSM.sql_for_area(ymin, xmin, ymax, xmax, "current_nodes.")} - EOF - return ActiveRecord::Base.connection.select_all(sql).collect { |n| [n['id'].to_i,n['lon'].to_f,n['lat'].to_f,tagstring_to_hash(n['tags'])] } + EOF + ActiveRecord::Base.connection.select_all(sql).each do |row| + poitags={} + ActiveRecord::Base.connection.select_all("SELECT k,v FROM current_node_tags WHERE id=#{row['id']}").each do |n| + poitags[n['k']]=n['v'] + end + pois << [row['id'].to_i, row['lon'].to_f, row['lat'].to_f, poitags, row['version'].to_i] + end + pois end def sql_find_relations_in_area_and_ways(xmin,ymin,xmax,ymax,way_ids) - # ** It would be more Potlatchy to get relations for nodes within ways - # during 'getway', not here - sql=<<-EOF - SELECT DISTINCT cr.id AS relid - FROM current_relations cr - INNER JOIN current_relation_members crm ON crm.id=cr.id - INNER JOIN current_nodes cn ON crm.member_id=cn.id AND crm.member_type='node' - WHERE #{OSM.sql_for_area(ymin, xmin, ymax, xmax, "cn.")} - EOF - unless way_ids.empty? - sql+=<<-EOF - UNION - SELECT DISTINCT cr.id AS relid - FROM current_relations cr - INNER JOIN current_relation_members crm ON crm.id=cr.id - WHERE crm.member_type='way' - AND crm.member_id IN (#{way_ids.join(',')}) - EOF - end - return ActiveRecord::Base.connection.select_all(sql).collect { |a| a['relid'].to_i }.uniq + # ** It would be more Potlatchy to get relations for nodes within ways + # during 'getway', not here + sql=<<-EOF + SELECT DISTINCT cr.id AS relid,cr.version AS version + FROM current_relations cr + INNER JOIN current_relation_members crm ON crm.id=cr.id + INNER JOIN current_nodes cn ON crm.member_id=cn.id AND crm.member_type='node' + WHERE #{OSM.sql_for_area(ymin, xmin, ymax, xmax, "cn.")} + EOF + unless way_ids.empty? + sql+=<<-EOF + UNION + SELECT DISTINCT cr.id AS relid,cr.version AS version + FROM current_relations cr + INNER JOIN current_relation_members crm ON crm.id=cr.id + WHERE crm.member_type='way' + AND crm.member_id IN (#{way_ids.join(',')}) + EOF + end + return ActiveRecord::Base.connection.select_all(sql).collect { |a| [a['relid'].to_i,a['version'].to_i] } end def sql_get_nodes_in_way(wayid) - points=[] - sql=<<-EOF - SELECT latitude*0.0000001 AS lat,longitude*0.0000001 AS lon,current_nodes.id,tags - FROM current_way_nodes,current_nodes - WHERE current_way_nodes.id=#{wayid.to_i} + points=[] + sql=<<-EOF + SELECT latitude*0.0000001 AS lat,longitude*0.0000001 AS lon,current_nodes.id + FROM current_way_nodes,current_nodes + WHERE current_way_nodes.id=#{wayid.to_i} AND current_way_nodes.node_id=current_nodes.id - AND current_nodes.visible=1 - ORDER BY sequence_id + AND current_nodes.visible=TRUE + ORDER BY sequence_id EOF - ActiveRecord::Base.connection.select_all(sql).each do |row| - nodetags=tagstring_to_hash(row['tags']) - nodetags.delete('created_by') - points << [row['lon'].to_f,row['lat'].to_f,row['id'].to_i,nodetags] - end - points + ActiveRecord::Base.connection.select_all(sql).each do |row| + nodetags={} + ActiveRecord::Base.connection.select_all("SELECT k,v FROM current_node_tags WHERE id=#{row['id']}").each do |n| + nodetags[n['k']]=n['v'] + end + nodetags.delete('created_by') + points << [row['lon'].to_f,row['lat'].to_f,row['id'].to_i,nodetags] + end + points end def sql_get_tags_in_way(wayid) - tags={} - ActiveRecord::Base.connection.select_all("SELECT k,v FROM current_way_tags WHERE id=#{wayid.to_i}").each do |row| - tags[row['k']]=row['v'] - end - tags + tags={} + ActiveRecord::Base.connection.select_all("SELECT k,v FROM current_way_tags WHERE id=#{wayid.to_i}").each do |row| + tags[row['k']]=row['v'] + end + tags end + def sql_get_way_version(wayid) + ActiveRecord::Base.connection.select_one("SELECT version FROM current_ways WHERE id=#{wayid.to_i}") + end end -# Local Variables: -# indent-tabs-mode: t -# tab-width: 4 -# End: diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index 029e4c8a6..ebf729afc 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -11,12 +11,13 @@ class ApiController < ApplicationController @@count = COUNT # The maximum area you're allowed to request, in square degrees - MAX_REQUEST_AREA = 0.25 + MAX_REQUEST_AREA = APP_CONFIG['max_request_area'] # Number of GPS trace/trackpoints returned per-page - TRACEPOINTS_PER_PAGE = 5000 + TRACEPOINTS_PER_PAGE = APP_CONFIG['tracepoints_per_page'] - + # Get an XML response containing a list of tracepoints that have been uploaded + # within the specified bounding box, and in the specified page. def trackpoints @@count+=1 #retrieve the page number @@ -84,6 +85,15 @@ class ApiController < ApplicationController render :text => doc.to_s, :content_type => "text/xml" end + # This is probably the most common call of all. It is used for getting the + # OSM data for a specified bounding box, usually for editing. First the + # bounding box (bbox) is checked to make sure that it is sane. All nodes + # are searched, then all the ways that reference those nodes are found. + # All Nodes that are referenced by those ways are fetched and added to the list + # of nodes. + # Then all the relations that reference the already found nodes and ways are + # fetched. All the nodes and ways that are referenced by those ways are then + # fetched. Finally all the xml is returned. def map GC.start @@count+=1 @@ -109,18 +119,19 @@ class ApiController < ApplicationController return end - @nodes = Node.find_by_area(min_lat, min_lon, max_lat, max_lon, :conditions => "visible = 1", :limit => APP_CONFIG['max_number_of_nodes']+1) + # FIXME um why is this area using a different order for the lat/lon from above??? + @nodes = Node.find_by_area(min_lat, min_lon, max_lat, max_lon, :conditions => {:visible => true}, :limit => APP_CONFIG['max_number_of_nodes']+1) # get all the nodes, by tag not yet working, waiting for change from NickB # need to be @nodes (instance var) so tests in /spec can be performed #@nodes = Node.search(bbox, params[:tag]) node_ids = @nodes.collect(&:id) if node_ids.length > APP_CONFIG['max_number_of_nodes'] - report_error("You requested too many nodes (limit is 50,000). Either request a smaller area, or use planet.osm") + report_error("You requested too many nodes (limit is #{APP_CONFIG['max_number_of_nodes']}). Either request a smaller area, or use planet.osm") return end if node_ids.length == 0 - render :text => "", :content_type => "text/xml" + render :text => "", :content_type => "text/xml" return end @@ -176,15 +187,15 @@ class ApiController < ApplicationController end end - relations = Relation.find_for_nodes(visible_nodes.keys, :conditions => "visible = 1") + - Relation.find_for_ways(way_ids, :conditions => "visible = 1") + relations = Relation.find_for_nodes(visible_nodes.keys, :conditions => {:visible => true}) + + Relation.find_for_ways(way_ids, :conditions => {:visible => true}) # we do not normally return the "other" partners referenced by an relation, # e.g. if we return a way A that is referenced by relation X, and there's # another way B also referenced, that is not returned. But we do make # an exception for cases where an relation references another *relation*; # in that case we return that as well (but we don't go recursive here) - relations += Relation.find_for_relations(relations.collect { |r| r.id }, :conditions => "visible = 1") + relations += Relation.find_for_relations(relations.collect { |r| r.id }, :conditions => {:visible => true}) # this "uniq" may be slightly inefficient; it may be better to first collect and output # all node-related relations, then find the *not yet covered* way-related ones etc. @@ -204,6 +215,8 @@ class ApiController < ApplicationController end end + # Get a list of the tiles that have changed within a specified time + # period def changes zoom = (params[:zoom] || '12').to_i @@ -212,12 +225,12 @@ class ApiController < ApplicationController endtime = Time.parse(params[:end]) else hours = (params[:hours] || '1').to_i.hours - endtime = Time.now + endtime = Time.now.getutc starttime = endtime - hours end if zoom >= 1 and zoom <= 16 and - endtime >= starttime and endtime - starttime <= 24.hours + endtime > starttime and endtime - starttime <= 24.hours mask = (1 << zoom) - 1 tiles = Node.count(:conditions => ["timestamp BETWEEN ? AND ?", starttime, endtime], @@ -245,21 +258,32 @@ class ApiController < ApplicationController render :text => doc.to_s, :content_type => "text/xml" else - render :nothing => true, :status => :bad_request + render :text => "Requested zoom is invalid, or the supplied start is after the end time, or the start duration is more than 24 hours", :status => :bad_request end end + # External apps that use the api are able to query the api to find out some + # parameters of the API. It currently returns: + # * minimum and maximum API versions that can be used. + # * maximum area that can be requested in a bbox request in square degrees + # * number of tracepoints that are returned in each tracepoints page def capabilities doc = OSM::API.new.get_xml_doc api = XML::Node.new 'api' version = XML::Node.new 'version' - version['minimum'] = '0.5'; - version['maximum'] = '0.5'; + version['minimum'] = "#{API_VERSION}"; + version['maximum'] = "#{API_VERSION}"; api << version area = XML::Node.new 'area' area['maximum'] = MAX_REQUEST_AREA.to_s; api << area + tracepoints = XML::Node.new 'tracepoints' + tracepoints['per_page'] = APP_CONFIG['tracepoints_per_page'].to_s + api << tracepoints + waynodes = XML::Node.new 'waynodes' + waynodes['maximum'] = APP_CONFIG['max_number_of_way_nodes'].to_s + api << waynodes doc.root << api diff --git a/app/controllers/application.rb b/app/controllers/application.rb index 615022656..21f691bb3 100644 --- a/app/controllers/application.rb +++ b/app/controllers/application.rb @@ -8,7 +8,7 @@ class ApplicationController < ActionController::Base def authorize_web if session[:user] - @user = User.find(session[:user], :conditions => "visible = 1") + @user = User.find(session[:user], :conditions => {:visible => true}) elsif session[:token] @user = User.authenticate(:token => session[:token]) session[:user] = @user.id @@ -22,7 +22,11 @@ class ApplicationController < ActionController::Base redirect_to :controller => 'user', :action => 'login', :referer => request.request_uri unless @user end - def authorize(realm='Web Password', errormessage="Couldn't authenticate you") + ## + # sets up the @user object for use by other methods. this is mostly called + # from the authorize method, but can be called elsewhere if authorisation + # is optional. + def setup_user_auth username, passwd = get_auth_data # parse from headers # authenticate per-scheme if username.nil? @@ -32,6 +36,11 @@ class ApplicationController < ActionController::Base else @user = User.authenticate(:username => username, :password => passwd) # basic auth end + end + + def authorize(realm='Web Password', errormessage="Couldn't authenticate you") + # make the @user object from any auth sources we have + setup_user_auth # handle authenticate pass/fail unless @user @@ -79,7 +88,7 @@ class ApplicationController < ActionController::Base # phrase from that, we can also put the error message into the status # message. For now, rails won't let us) def report_error(message) - render :nothing => true, :status => :bad_request + render :text => message, :status => :bad_request # Todo: some sort of escaping of problem characters in the message response.headers['Error'] = message end @@ -90,6 +99,8 @@ private def get_auth_data if request.env.has_key? 'X-HTTP_AUTHORIZATION' # where mod_rewrite might have put it authdata = request.env['X-HTTP_AUTHORIZATION'].to_s.split + elsif request.env.has_key? 'REDIRECT_X_HTTP_AUTHORIZATION' # mod_fcgi + authdata = request.env['REDIRECT_X_HTTP_AUTHORIZATION'].to_s.split elsif request.env.has_key? 'HTTP_AUTHORIZATION' # regular location authdata = request.env['HTTP_AUTHORIZATION'].to_s.split end diff --git a/app/controllers/browse_controller.rb b/app/controllers/browse_controller.rb index df48a2090..6ace0817b 100644 --- a/app/controllers/browse_controller.rb +++ b/app/controllers/browse_controller.rb @@ -7,103 +7,109 @@ class BrowseController < ApplicationController def start end - def index - @nodes = Node.find(:all, :order => "timestamp DESC", :limit=> 20) - end + def relation - begin - @relation = Relation.find(params[:id]) - - @name = @relation.tags['name'].to_s - if @name.length == 0: - @name = "#" + @relation.id.to_s - end - - @title = 'Relation | ' + (@name) - @next = Relation.find(:first, :order => "id ASC", :conditions => [ "visible = true AND id > :id", { :id => @relation.id }] ) - @prev = Relation.find(:first, :order => "id DESC", :conditions => [ "visible = true AND id < :id", { :id => @relation.id }] ) - rescue ActiveRecord::RecordNotFound - render :nothing => true, :status => :not_found + @relation = Relation.find(params[:id]) + + @name = @relation.tags['name'].to_s + if @name.length == 0: + @name = "#" + @relation.id.to_s end + + @title = 'Relation | ' + (@name) + @next = Relation.find(:first, :order => "id ASC", :conditions => [ "visible = true AND id > :id", { :id => @relation.id }] ) + @prev = Relation.find(:first, :order => "id DESC", :conditions => [ "visible = true AND id < :id", { :id => @relation.id }] ) + rescue ActiveRecord::RecordNotFound + @type = "relation" + render :action => "not_found", :status => :not_found end def relation_history - begin - @relation = Relation.find(params[:id]) + @relation = Relation.find(params[:id]) - @name = @relation.tags['name'].to_s - if @name.length == 0: - @name = "#" + @relation.id.to_s - end - - @title = 'Relation History | ' + (@name) - rescue ActiveRecord::RecordNotFound - render :nothing => true, :status => :not_found + @name = @relation.tags['name'].to_s + if @name.length == 0: + @name = "#" + @relation.id.to_s end + + @title = 'Relation History | ' + (@name) + rescue ActiveRecord::RecordNotFound + @type = "relation" + render :action => "not_found", :status => :not_found end def way - begin - @way = Way.find(params[:id]) + @way = Way.find(params[:id]) - @name = @way.tags['name'].to_s - if @name.length == 0: - @name = "#" + @way.id.to_s - end - - @title = 'Way | ' + (@name) - @next = Way.find(:first, :order => "id ASC", :conditions => [ "visible = true AND id > :id", { :id => @way.id }] ) - @prev = Way.find(:first, :order => "id DESC", :conditions => [ "visible = true AND id < :id", { :id => @way.id }] ) - rescue ActiveRecord::RecordNotFound - render :nothing => true, :status => :not_found + @name = @way.tags['name'].to_s + if @name.length == 0: + @name = "#" + @way.id.to_s end + + @title = 'Way | ' + (@name) + @next = Way.find(:first, :order => "id ASC", :conditions => [ "visible = true AND id > :id", { :id => @way.id }] ) + @prev = Way.find(:first, :order => "id DESC", :conditions => [ "visible = true AND id < :id", { :id => @way.id }] ) + rescue ActiveRecord::RecordNotFound + @type = "way" + render :action => "not_found", :status => :not_found end def way_history - begin - @way = Way.find(params[:id]) + @way = Way.find(params[:id]) - @name = @way.tags['name'].to_s - if @name.length == 0: - @name = "#" + @way.id.to_s - end - - @title = 'Way History | ' + (@name) - rescue ActiveRecord::RecordNotFound - render :nothing => true, :status => :not_found + @name = @way.tags['name'].to_s + if @name.length == 0: + @name = "#" + @way.id.to_s end + + @title = 'Way History | ' + (@name) + rescue ActiveRecord::RecordNotFound + @type = "way" + render :action => "not_found", :status => :not_found end def node - begin - @node = Node.find(params[:id]) + @node = Node.find(params[:id]) - @name = @node.tags_as_hash['name'].to_s - if @name.length == 0: - @name = "#" + @node.id.to_s - end - - @title = 'Node | ' + (@name) - @next = Node.find(:first, :order => "id ASC", :conditions => [ "visible = true AND id > :id", { :id => @node.id }] ) - @prev = Node.find(:first, :order => "id DESC", :conditions => [ "visible = true AND id < :id", { :id => @node.id }] ) - rescue ActiveRecord::RecordNotFound - render :nothing => true, :status => :not_found + @name = @node.tags_as_hash['name'].to_s + if @name.length == 0: + @name = "#" + @node.id.to_s end + + @title = 'Node | ' + (@name) + @next = Node.find(:first, :order => "id ASC", :conditions => [ "visible = true AND id > :id", { :id => @node.id }] ) + @prev = Node.find(:first, :order => "id DESC", :conditions => [ "visible = true AND id < :id", { :id => @node.id }] ) + rescue ActiveRecord::RecordNotFound + @type = "node" + render :action => "not_found", :status => :not_found end def node_history - begin - @node = Node.find(params[:id]) + @node = Node.find(params[:id]) - @name = @node.tags_as_hash['name'].to_s - if @name.length == 0: - @name = "#" + @node.id.to_s - end - - @title = 'Node History | ' + (@name) - rescue ActiveRecord::RecordNotFound - render :nothing => true, :status => :not_found + @name = @node.tags_as_hash['name'].to_s + if @name.length == 0: + @name = "#" + @node.id.to_s end + + @title = 'Node History | ' + (@name) + rescue ActiveRecord::RecordNotFound + @type = "way" + render :action => "not_found", :status => :not_found + end + + def changeset + @changeset = Changeset.find(params[:id]) + @node_pages, @nodes = paginate(:old_nodes, :conditions => {:changeset_id => @changeset.id}, :per_page => 20, :parameter => 'node_page') + @way_pages, @ways = paginate(:old_ways, :conditions => {:changeset_id => @changeset.id}, :per_page => 20, :parameter => 'way_page') + @relation_pages, @relations = paginate(:old_relations, :conditions => {:changeset_id => @changeset.id}, :per_page => 20, :parameter => 'relation_page') + + @title = "Changeset | #{@changeset.id}" + @next = Changeset.find(:first, :order => "id ASC", :conditions => [ "id > :id", { :id => @changeset.id }] ) + @prev = Changeset.find(:first, :order => "id DESC", :conditions => [ "id < :id", { :id => @changeset.id }] ) + rescue ActiveRecord::RecordNotFound + @type = "changeset" + render :action => "not_found", :status => :not_found end end diff --git a/app/controllers/changeset_controller.rb b/app/controllers/changeset_controller.rb new file mode 100644 index 000000000..bba012a9c --- /dev/null +++ b/app/controllers/changeset_controller.rb @@ -0,0 +1,484 @@ +# The ChangesetController is the RESTful interface to Changeset objects + +class ChangesetController < ApplicationController + layout 'site' + require 'xml/libxml' + + session :off, :except => [:list, :list_user, :list_bbox] + before_filter :authorize_web, :only => [:list, :list_user, :list_bbox] + before_filter :authorize, :only => [:create, :update, :delete, :upload, :include, :close] + before_filter :check_write_availability, :only => [:create, :update, :delete, :upload, :include] + before_filter :check_read_availability, :except => [:create, :update, :delete, :upload, :download, :query] + after_filter :compress_output + + # Help methods for checking boundary sanity and area size + include MapBoundary + + # Helper methods for checking consistency + include ConsistencyValidations + + # Create a changeset from XML. + def create + if request.put? + cs = Changeset.from_xml(request.raw_post, true) + + if cs + cs.user_id = @user.id + cs.save_with_tags! + render :text => cs.id.to_s, :content_type => "text/plain" + else + render :nothing => true, :status => :bad_request + end + else + render :nothing => true, :status => :method_not_allowed + end + end + + ## + # Return XML giving the basic info about the changeset. Does not + # return anything about the nodes, ways and relations in the changeset. + def read + begin + changeset = Changeset.find(params[:id]) + render :text => changeset.to_xml.to_s, :content_type => "text/xml" + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => :not_found + end + end + + ## + # marks a changeset as closed. this may be called multiple times + # on the same changeset, so is idempotent. + def close + unless request.put? + render :nothing => true, :status => :method_not_allowed + return + end + + changeset = Changeset.find(params[:id]) + check_changeset_consistency(changeset, @user) + + # to close the changeset, we'll just set its closed_at time to + # now. this might not be enough if there are concurrency issues, + # but we'll have to wait and see. + changeset.set_closed_time_now + + changeset.save! + render :nothing => true + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => :not_found + rescue OSM::APIError => ex + render ex.render_opts + end + + ## + # insert a (set of) points into a changeset bounding box. this can only + # increase the size of the bounding box. this is a hint that clients can + # set either before uploading a large number of changes, or changes that + # the client (but not the server) knows will affect areas further away. + def expand_bbox + # only allow POST requests, because although this method is + # idempotent, there is no "document" to PUT really... + if request.post? + cs = Changeset.find(params[:id]) + check_changeset_consistency(cs, @user) + + # keep an array of lons and lats + lon = Array.new + lat = Array.new + + # the request is in pseudo-osm format... this is kind-of an + # abuse, maybe should change to some other format? + doc = XML::Parser.string(request.raw_post).parse + doc.find("//osm/node").each do |n| + lon << n['lon'].to_f * GeoRecord::SCALE + lat << n['lat'].to_f * GeoRecord::SCALE + end + + # add the existing bounding box to the lon-lat array + lon << cs.min_lon unless cs.min_lon.nil? + lat << cs.min_lat unless cs.min_lat.nil? + lon << cs.max_lon unless cs.max_lon.nil? + lat << cs.max_lat unless cs.max_lat.nil? + + # collapse the arrays to minimum and maximum + cs.min_lon, cs.min_lat, cs.max_lon, cs.max_lat = + lon.min, lat.min, lon.max, lat.max + + # save the larger bounding box and return the changeset, which + # will include the bigger bounding box. + cs.save! + render :text => cs.to_xml.to_s, :content_type => "text/xml" + + else + render :nothing => true, :status => :method_not_allowed + end + + rescue LibXML::XML::Error, ArgumentError => ex + raise OSM::APIBadXMLError.new("osm", xml, ex.message) + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => :not_found + rescue OSM::APIError => ex + render ex.render_opts + end + + ## + # Upload a diff in a single transaction. + # + # This means that each change within the diff must succeed, i.e: that + # each version number mentioned is still current. Otherwise the entire + # transaction *must* be rolled back. + # + # Furthermore, each element in the diff can only reference the current + # changeset. + # + # Returns: a diffResult document, as described in + # http://wiki.openstreetmap.org/index.php/OSM_Protocol_Version_0.6 + def upload + # only allow POST requests, as the upload method is most definitely + # not idempotent, as several uploads with placeholder IDs will have + # different side-effects. + # see http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.1.2 + unless request.post? + render :nothing => true, :status => :method_not_allowed + return + end + + changeset = Changeset.find(params[:id]) + check_changeset_consistency(changeset, @user) + + diff_reader = DiffReader.new(request.raw_post, changeset) + Changeset.transaction do + result = diff_reader.commit + render :text => result.to_s, :content_type => "text/xml" + end + + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => :not_found + rescue OSM::APIError => ex + render ex.render_opts + end + + ## + # download the changeset as an osmChange document. + # + # to make it easier to revert diffs it would be better if the osmChange + # format were reversible, i.e: contained both old and new versions of + # modified elements. but it doesn't at the moment... + # + # this method cannot order the database changes fully (i.e: timestamp and + # version number may be too coarse) so the resulting diff may not apply + # to a different database. however since changesets are not atomic this + # behaviour cannot be guaranteed anyway and is the result of a design + # choice. + def download + changeset = Changeset.find(params[:id]) + + # get all the elements in the changeset and stick them in a big array. + elements = [changeset.old_nodes, + changeset.old_ways, + changeset.old_relations].flatten + + # sort the elements by timestamp and version number, as this is the + # almost sensible ordering available. this would be much nicer if + # global (SVN-style) versioning were used - then that would be + # unambiguous. + elements.sort! do |a, b| + if (a.timestamp == b.timestamp) + a.version <=> b.version + else + a.timestamp <=> b.timestamp + end + end + + # create an osmChange document for the output + result = OSM::API.new.get_xml_doc + result.root.name = "osmChange" + + # generate an output element for each operation. note: we avoid looking + # at the history because it is simpler - but it would be more correct to + # check these assertions. + elements.each do |elt| + result.root << + if (elt.version == 1) + # first version, so it must be newly-created. + created = XML::Node.new "create" + created << elt.to_xml_node + else + # get the previous version from the element history + prev_elt = elt.class.find(:first, :conditions => + ['id = ? and version = ?', + elt.id, elt.version]) + unless elt.visible + # if the element isn't visible then it must have been deleted, so + # output the *previous* XML + deleted = XML::Node.new "delete" + deleted << prev_elt.to_xml_node + else + # must be a modify, for which we don't need the previous version + # yet... + modified = XML::Node.new "modify" + modified << elt.to_xml_node + end + end + end + + render :text => result.to_s, :content_type => "text/xml" + + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => :not_found + rescue OSM::APIError => ex + render ex.render_opts + end + + ## + # query changesets by bounding box, time, user or open/closed status. + def query + # create the conditions that the user asked for. some or all of + # these may be nil. + conditions = conditions_bbox(params['bbox']) + conditions = cond_merge conditions, conditions_user(params['user']) + conditions = cond_merge conditions, conditions_time(params['time']) + conditions = cond_merge conditions, conditions_open(params['open']) + conditions = cond_merge conditions, conditions_closed(params['closed']) + + # create the results document + results = OSM::API.new.get_xml_doc + + # add all matching changesets to the XML results document + Changeset.find(:all, + :conditions => conditions, + :limit => 100, + :order => 'created_at desc').each do |cs| + results.root << cs.to_xml_node + end + + render :text => results.to_s, :content_type => "text/xml" + + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => :not_found + rescue OSM::APIError => ex + render ex.render_opts + end + + ## + # updates a changeset's tags. none of the changeset's attributes are + # user-modifiable, so they will be ignored. + # + # changesets are not (yet?) versioned, so we don't have to deal with + # history tables here. changesets are locked to a single user, however. + # + # after succesful update, returns the XML of the changeset. + def update + # request *must* be a PUT. + unless request.put? + render :nothing => true, :status => :method_not_allowed + return + end + + changeset = Changeset.find(params[:id]) + new_changeset = Changeset.from_xml(request.raw_post) + + unless new_changeset.nil? + check_changeset_consistency(changeset, @user) + changeset.update_from(new_changeset, @user) + render :text => changeset.to_xml, :mime_type => "text/xml" + else + + render :nothing => true, :status => :bad_request + end + + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => :not_found + rescue OSM::APIError => ex + render ex.render_opts + end + + + + ## + # list edits (open changesets) in reverse chronological order + def list + conditions = conditions_nonempty + + + # @changesets = Changeset.find(:all, :order => "closed_at DESC", :conditions => ['closed_at < ?', DateTime.now], :limit=> 20) + + + #@edit_pages, @edits = paginate(:changesets, + # :include => [:user, :changeset_tags], + # :conditions => conditions, + # :order => "changesets.created_at DESC", + # :per_page => 20) + # + + @edits = Changeset.find(:all, + :order => "changesets.created_at DESC", + :conditions => conditions, + :limit => 20) + + end + + ## + # list edits (changesets) belonging to a user + def list_user + user = User.find_by_display_name(params[:display_name], :conditions => {:visible => true}) + + if user + @display_name = user.display_name + if not user.data_public? and @user != user + @edits = nil + render + else + conditions = cond_merge conditions, ['user_id = ?', user.id] + conditions = cond_merge conditions, conditions_nonempty + @edit_pages, @edits = paginate(:changesets, + :include => [:user, :changeset_tags], + :conditions => conditions, + :order => "changesets.created_at DESC", + :per_page => 20) + end + else + @not_found_user = params[:display_name] + render :template => 'user/no_such_user', :status => :not_found + end + end + + ## + # list changesets in a bbox + def list_bbox + # support 'bbox' param or alternatively 'minlon', 'minlat' etc + if params['bbox'] + bbox = params['bbox'] + elsif params['minlon'] and params['minlat'] and params['maxlon'] and params['maxlat'] + bbox = h(params['minlon']) + ',' + h(params['minlat']) + ',' + h(params['maxlon']) + ',' + h(params['maxlat']) + else + #TODO: fix bugs in location determination for history tab (and other tabs) then uncomment this redirect + #redirect_to :action => 'list' + end + + conditions = conditions_bbox(bbox); + conditions = cond_merge conditions, conditions_nonempty + + @edit_pages, @edits = paginate(:changesets, + :include => [:user, :changeset_tags], + :conditions => conditions, + :order => "changesets.created_at DESC", + :per_page => 20) + + @bbox = sanitise_boundaries(bbox.split(/,/)) unless bbox==nil + end + +private + #------------------------------------------------------------ + # utility functions below. + #------------------------------------------------------------ + + ## + # merge two conditions + def cond_merge(a, b) + if a and b + a_str = a.shift + b_str = b.shift + return [ a_str + " AND " + b_str ] + a + b + elsif a + return a + else b + return b + end + end + + ## + # if a bounding box was specified then parse it and do some sanity + # checks. this is mostly the same as the map call, but without the + # area restriction. + def conditions_bbox(bbox) + unless bbox.nil? + raise OSM::APIBadUserInput.new("Bounding box should be min_lon,min_lat,max_lon,max_lat") unless bbox.count(',') == 3 + bbox = sanitise_boundaries(bbox.split(/,/)) + raise OSM::APIBadUserInput.new("Minimum longitude should be less than maximum.") unless bbox[0] <= bbox[2] + raise OSM::APIBadUserInput.new("Minimum latitude should be less than maximum.") unless bbox[1] <= bbox[3] + return ['min_lon < ? and max_lon > ? and min_lat < ? and max_lat > ?', + bbox[2] * GeoRecord::SCALE, bbox[0] * GeoRecord::SCALE, bbox[3]* GeoRecord::SCALE, bbox[1] * GeoRecord::SCALE] + else + return nil + end + end + + ## + # restrict changesets to those by a particular user + def conditions_user(user) + unless user.nil? + # user input checking, we don't have any UIDs < 1 + raise OSM::APIBadUserInput.new("invalid user ID") if user.to_i < 1 + + u = User.find(user.to_i) + # should be able to get changesets of public users only, or + # our own changesets regardless of public-ness. + unless u.data_public? + # get optional user auth stuff so that users can see their own + # changesets if they're non-public + setup_user_auth + + raise OSM::APINotFoundError if @user.nil? or @user.id != u.id + end + return ['user_id = ?', u.id] + else + return nil + end + end + + ## + # restrict changes to those closed during a particular time period + def conditions_time(time) + unless time.nil? + # if there is a range, i.e: comma separated, then the first is + # low, second is high - same as with bounding boxes. + if time.count(',') == 1 + # check that we actually have 2 elements in the array + times = time.split(/,/) + raise OSM::APIBadUserInput.new("bad time range") if times.size != 2 + + from, to = times.collect { |t| DateTime.parse(t) } + return ['closed_at >= ? and created_at <= ?', from, to] + else + # if there is no comma, assume its a lower limit on time + return ['closed_at >= ?', DateTime.parse(time)] + end + else + return nil + end + # stupid DateTime seems to throw both of these for bad parsing, so + # we have to catch both and ensure the correct code path is taken. + rescue ArgumentError => ex + raise OSM::APIBadUserInput.new(ex.message.to_s) + rescue RuntimeError => ex + raise OSM::APIBadUserInput.new(ex.message.to_s) + end + + ## + # return changesets which are open (haven't been closed yet) + # we do this by seeing if the 'closed at' time is in the future. Also if we've + # hit the maximum number of changes then it counts as no longer open. + # if parameter 'open' is nill then open and closed changsets are returned + def conditions_open(open) + return open.nil? ? nil : ['closed_at >= ? and num_changes <= ?', + Time.now.getutc, Changeset::MAX_ELEMENTS] + end + + ## + # query changesets which are closed + # ('closed at' time has passed or changes limit is hit) + def conditions_closed(closed) + return closed.nil? ? nil : ['closed_at < ? and num_changes > ?', + Time.now.getutc, Changeset::MAX_ELEMENTS] + end + + ## + # eliminate empty changesets (where the bbox has not been set) + # this should be applied to all changeset list displays + def conditions_nonempty() + return ['min_lat IS NOT NULL'] + end + +end diff --git a/app/controllers/changeset_tag_controller.rb b/app/controllers/changeset_tag_controller.rb new file mode 100644 index 000000000..3e8db3fc2 --- /dev/null +++ b/app/controllers/changeset_tag_controller.rb @@ -0,0 +1,9 @@ +class ChangesetTagController < ApplicationController + layout 'site' + + def search + @tags = ChangesetTag.find(:all, :limit => 11, :conditions => ["match(v) against (?)", params[:query][:query].to_s] ) + end + + +end diff --git a/app/controllers/diary_entry_controller.rb b/app/controllers/diary_entry_controller.rb index 52e2ab22b..3ee36af21 100644 --- a/app/controllers/diary_entry_controller.rb +++ b/app/controllers/diary_entry_controller.rb @@ -39,6 +39,8 @@ class DiaryEntryController < ApplicationController redirect_to :controller => 'diary_entry', :action => 'view', :id => params[:id] end end + rescue ActiveRecord::RecordNotFound + render :action => "no_such_entry", :status => :not_found end def comment @@ -55,7 +57,7 @@ class DiaryEntryController < ApplicationController def list if params[:display_name] - @this_user = User.find_by_display_name(params[:display_name], :conditions => "visible = 1") + @this_user = User.find_by_display_name(params[:display_name], :conditions => {:visible => true}) if @this_user @title = @this_user.display_name + "'s diary" @@ -71,7 +73,7 @@ class DiaryEntryController < ApplicationController else @title = "Users' diaries" @entry_pages, @entries = paginate(:diary_entries, :include => :user, - :conditions => "users.visible = 1", + :conditions => ["users.visible = ?", true], :order => 'created_at DESC', :per_page => 20) end @@ -79,13 +81,13 @@ class DiaryEntryController < ApplicationController def rss if params[:display_name] - user = User.find_by_display_name(params[:display_name], :conditions => "visible = 1") + user = User.find_by_display_name(params[:display_name], :conditions => {:visible => true}) if user @entries = DiaryEntry.find(:all, :conditions => ['user_id = ?', user.id], :order => 'created_at DESC', :limit => 20) @title = "OpenStreetMap diary entries for #{user.display_name}" @description = "Recent OpenStreetmap diary entries from #{user.display_name}" - @link = "http://www.openstreetmap.org/user/#{user.display_name}/diary" + @link = "http://#{SERVER_URL}/user/#{user.display_name}/diary" render :content_type => Mime::RSS else @@ -93,21 +95,22 @@ class DiaryEntryController < ApplicationController end else @entries = DiaryEntry.find(:all, :include => :user, - :conditions => "users.visible = 1", + :conditions => ["users.visible = ?", true], :order => 'created_at DESC', :limit => 20) @title = "OpenStreetMap diary entries" @description = "Recent diary entries from users of OpenStreetMap" - @link = "http://www.openstreetmap.org/diary" + @link = "http://#{SERVER_URL}/diary" render :content_type => Mime::RSS end end def view - user = User.find_by_display_name(params[:display_name], :conditions => "visible = 1") + user = User.find_by_display_name(params[:display_name], :conditions => {:visible => true}) if user @entry = DiaryEntry.find(:first, :conditions => ['user_id = ? AND id = ?', user.id, params[:id]]) + @title = "Users' diaries | #{params[:display_name]}" else @not_found_user = params[:display_name] diff --git a/app/controllers/export_controller.rb b/app/controllers/export_controller.rb index a773d4b72..754cc4b82 100644 --- a/app/controllers/export_controller.rb +++ b/app/controllers/export_controller.rb @@ -2,18 +2,24 @@ class ExportController < ApplicationController def start end + #When the user clicks 'Export' we redirect to a URL which generates the export download def finish bbox = BoundingBox.new(params[:minlon], params[:minlat], params[:maxlon], params[:maxlat]) format = params[:format] if format == "osm" + #redirect to API map get redirect_to "http://api.openstreetmap.org/api/#{API_VERSION}/map?bbox=#{bbox}" + elsif format == "mapnik" + #redirect to a special 'export' cgi script format = params[:mapnik_format] scale = params[:mapnik_scale] redirect_to "http://tile.openstreetmap.org/cgi-bin/export?bbox=#{bbox}&scale=#{scale}&format=#{format}" + elsif format == "osmarender" + #redirect to the t@h 'MapOf' service format = params[:osmarender_format] zoom = params[:osmarender_zoom].to_i width = bbox.slippy_width(zoom).to_i diff --git a/app/controllers/message_controller.rb b/app/controllers/message_controller.rb index d6e5d7fda..8f866e512 100644 --- a/app/controllers/message_controller.rb +++ b/app/controllers/message_controller.rb @@ -6,13 +6,18 @@ class MessageController < ApplicationController before_filter :check_database_readable before_filter :check_database_writable, :only => [:new, :reply, :mark] + # Allow the user to write a new message to another user. This action also + # deals with the sending of that message to the other user when the user + # clicks send. + # The user_id param is the id of the user that the message is being sent to. def new @title = 'send message' + @to_user = User.find(params[:user_id]) if params[:message] @message = Message.new(params[:message]) - @message.to_user_id = params[:user_id] + @message.to_user_id = @to_user.id @message.from_user_id = @user.id - @message.sent_on = Time.now + @message.sent_on = Time.now.getutc if @message.save flash[:notice] = 'Message sent' @@ -22,27 +27,33 @@ class MessageController < ApplicationController else @title = params[:title] end + rescue ActiveRecord::RecordNotFound + render :action => 'no_such_user', :status => :not_found end + # Allow the user to reply to another message. def reply message = Message.find(params[:message_id], :conditions => ["to_user_id = ? or from_user_id = ?", @user.id, @user.id ]) @body = "On #{message.sent_on} #{message.sender.display_name} wrote:\n\n#{message.body.gsub(/^/, '> ')}" @title = "Re: #{message.title.sub(/^Re:\s*/, '')}" @user_id = message.from_user_id + @to_user = User.find(message.to_user_id) render :action => 'new' rescue ActiveRecord::RecordNotFound - render :nothing => true, :status => :not_found + render :action => 'no_such_user', :status => :not_found end + # Show a message def read @title = 'read message' @message = Message.find(params[:message_id], :conditions => ["to_user_id = ? or from_user_id = ?", @user.id, @user.id ]) - @message.message_read = 1 if @message.to_user_id == @user.id + @message.message_read = true if @message.to_user_id == @user.id @message.save rescue ActiveRecord::RecordNotFound - render :nothing => true, :status => :not_found + render :action => 'no_such_user', :status => :not_found end + # Display the list of messages that have been sent to the user. def inbox @title = 'inbox' if @user and params[:display_name] == @user.display_name @@ -51,6 +62,7 @@ class MessageController < ApplicationController end end + # Display the list of messages that the user has sent to other users. def outbox @title = 'outbox' if @user and params[:display_name] == @user.display_name @@ -59,15 +71,16 @@ class MessageController < ApplicationController end end + # Set the message as being read or unread. def mark if params[:message_id] id = params[:message_id] message = Message.find_by_id(id) if params[:mark] == 'unread' - message_read = 0 + message_read = false mark_type = 'unread' else - message_read = 1 + message_read = true mark_type = 'read' end message.message_read = message_read @@ -76,5 +89,7 @@ class MessageController < ApplicationController redirect_to :controller => 'message', :action => 'inbox', :display_name => @user.display_name end end + rescue ActiveRecord::RecordNotFound + render :action => 'no_such_user', :status => :not_found end end diff --git a/app/controllers/node_controller.rb b/app/controllers/node_controller.rb index 6ce18f566..80a3b30d5 100644 --- a/app/controllers/node_controller.rb +++ b/app/controllers/node_controller.rb @@ -11,20 +11,21 @@ class NodeController < ApplicationController # Create a node from XML. def create - if request.put? - node = Node.from_xml(request.raw_post, true) - - if node - node.user_id = @user.id - node.visible = true - node.save_with_history! + begin + if request.put? + node = Node.from_xml(request.raw_post, true) - render :text => node.id.to_s, :content_type => "text/plain" + if node + node.create_with_history @user + render :text => node.id.to_s, :content_type => "text/plain" + else + render :nothing => true, :status => :bad_request + end else - render :nothing => true, :status => :bad_request + render :nothing => true, :status => :method_not_allowed end - else - render :nothing => true, :status => :method_not_allowed + rescue OSM::APIError => ex + render ex.render_opts end end @@ -32,7 +33,7 @@ class NodeController < ApplicationController def read begin node = Node.find(params[:id]) - if node.visible + if node.visible? response.headers['Last-Modified'] = node.timestamp.rfc822 render :text => node.to_xml.to_s, :content_type => "text/xml" else @@ -42,7 +43,7 @@ class NodeController < ApplicationController render :nothing => true, :status => :not_found end end - + # Update a node from given XML def update begin @@ -50,49 +51,40 @@ class NodeController < ApplicationController new_node = Node.from_xml(request.raw_post) if new_node and new_node.id == node.id - node.user_id = @user.id - node.latitude = new_node.latitude - node.longitude = new_node.longitude - node.tags = new_node.tags - node.visible = true - node.save_with_history! - - render :nothing => true + node.update_from(new_node, @user) + render :text => node.version.to_s, :content_type => "text/plain" else render :nothing => true, :status => :bad_request end + rescue OSM::APIError => ex + render ex.render_opts rescue ActiveRecord::RecordNotFound render :nothing => true, :status => :not_found end end - # Delete a node. Doesn't actually delete it, but retains its history in a wiki-like way. - # FIXME remove all the fricking SQL + # Delete a node. Doesn't actually delete it, but retains its history + # in a wiki-like way. We therefore treat it like an update, so the delete + # method returns the new version number. def delete begin node = Node.find(params[:id]) - - if node.visible - if WayNode.find(:first, :joins => "INNER JOIN current_ways ON current_ways.id = current_way_nodes.id", :conditions => [ "current_ways.visible = 1 AND current_way_nodes.node_id = ?", node.id ]) - render :text => "", :status => :precondition_failed - elsif RelationMember.find(:first, :joins => "INNER JOIN current_relations ON current_relations.id=current_relation_members.id", :conditions => [ "visible = 1 AND member_type='node' and member_id=?", params[:id]]) - render :text => "", :status => :precondition_failed - else - node.user_id = @user.id - node.visible = 0 - node.save_with_history! - - render :nothing => true - end + new_node = Node.from_xml(request.raw_post) + + if new_node and new_node.id == node.id + node.delete_with_history!(new_node, @user) + render :text => node.version.to_s, :content_type => "text/plain" else - render :text => "", :status => :gone + render :nothing => true, :status => :bad_request end rescue ActiveRecord::RecordNotFound render :nothing => true, :status => :not_found + rescue OSM::APIError => ex + render ex.render_opts end end - # WTF does this do? + # Dump the details on many nodes whose ids are given in the "nodes" parameter. def nodes ids = params['nodes'].split(',').collect { |n| n.to_i } diff --git a/app/controllers/old_node_controller.rb b/app/controllers/old_node_controller.rb index 40f4093e3..0976a0c9a 100644 --- a/app/controllers/old_node_controller.rb +++ b/app/controllers/old_node_controller.rb @@ -22,4 +22,21 @@ class OldNodeController < ApplicationController render :nothing => true, :status => :internal_server_error end end + + def version + begin + old_node = OldNode.find(:first, :conditions => {:id => params[:id], :version => params[:version]} ) + + response.headers['Last-Modified'] = old_node.timestamp.rfc822 + + doc = OSM::API.new.get_xml_doc + doc.root << old_node.to_xml_node + + render :text => doc.to_s, :content_type => "text/xml" + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => :not_found + rescue + render :nothing => true, :status => :internal_server_error + end + end end diff --git a/app/controllers/old_relation_controller.rb b/app/controllers/old_relation_controller.rb index 0b5aa89be..84d0b0c90 100644 --- a/app/controllers/old_relation_controller.rb +++ b/app/controllers/old_relation_controller.rb @@ -2,6 +2,7 @@ class OldRelationController < ApplicationController require 'xml/libxml' session :off + before_filter :check_read_availability after_filter :compress_output def history @@ -20,4 +21,21 @@ class OldRelationController < ApplicationController render :nothing => true, :status => :internal_server_error end end + + def version + begin + old_relation = OldRelation.find(:first, :conditions => {:id => params[:id], :version => params[:version]} ) + + response.headers['Last-Modified'] = old_relation.timestamp.rfc822 + + doc = OSM::API.new.get_xml_doc + doc.root << old_relation.to_xml_node + + render :text => doc.to_s, :content_type => "text/xml" + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => :not_found + rescue + render :nothing => true, :status => :internetal_service_error + end + end end diff --git a/app/controllers/old_way_controller.rb b/app/controllers/old_way_controller.rb index 3d913c0f7..a42496687 100644 --- a/app/controllers/old_way_controller.rb +++ b/app/controllers/old_way_controller.rb @@ -13,7 +13,7 @@ class OldWayController < ApplicationController way.old_ways.each do |old_way| doc.root << old_way.to_xml_node - end + end render :text => doc.to_s, :content_type => "text/xml" rescue ActiveRecord::RecordNotFound @@ -22,4 +22,21 @@ class OldWayController < ApplicationController render :nothing => true, :status => :internal_server_error end end + + def version + begin + old_way = OldWay.find(:first, :conditions => {:id => params[:id], :version => params[:version]} ) + + response.headers['Last-Modified'] = old_way.timestamp.rfc822 + + doc = OSM::API.new.get_xml_doc + doc.root << old_way.to_xml_node + + render :text => doc.to_s, :content_type => "text/xml" + rescue ActiveRecord::RecordNotFound + render :nothing => true, :status => :not_found + rescue + render :nothing => true, :status => :internal_server_error + end + end end diff --git a/app/controllers/relation_controller.rb b/app/controllers/relation_controller.rb index 457249162..3d3fa2185 100644 --- a/app/controllers/relation_controller.rb +++ b/app/controllers/relation_controller.rb @@ -8,23 +8,23 @@ class RelationController < ApplicationController after_filter :compress_output def create - if request.put? - relation = Relation.from_xml(request.raw_post, true) - - if relation - if !relation.preconditions_ok? - render :text => "", :status => :precondition_failed - else - relation.user_id = @user.id - relation.save_with_history! - - render :text => relation.id.to_s, :content_type => "text/plain" - end + begin + if request.put? + relation = Relation.from_xml(request.raw_post, true) + + # We assume that an exception has been thrown if there was an error + # generating the relation + #if relation + relation.create_with_history @user + render :text => relation.id.to_s, :content_type => "text/plain" + #else + # render :text => "Couldn't get turn the input into a relation.", :status => :bad_request + #end else - render :nothing => true, :status => :bad_request + render :nothing => true, :status => :method_not_allowed end - else - render :nothing => true, :status => :method_not_allowed + rescue OSM::APIError => ex + render ex.render_opts end end @@ -45,56 +45,38 @@ class RelationController < ApplicationController end def update + logger.debug request.raw_post begin relation = Relation.find(params[:id]) new_relation = Relation.from_xml(request.raw_post) if new_relation and new_relation.id == relation.id - if !new_relation.preconditions_ok? - render :text => "", :status => :precondition_failed - else - relation.user_id = @user.id - relation.tags = new_relation.tags - relation.members = new_relation.members - relation.visible = true - relation.save_with_history! - - render :nothing => true - end + relation.update_from new_relation, @user + render :text => relation.version.to_s, :content_type => "text/plain" else render :nothing => true, :status => :bad_request end rescue ActiveRecord::RecordNotFound render :nothing => true, :status => :not_found - rescue - render :nothing => true, :status => :internal_server_error + rescue OSM::APIError => ex + render ex.render_opts end end def delete -#XXX check if member somewhere! begin relation = Relation.find(params[:id]) - - if relation.visible - if RelationMember.find(:first, :joins => "INNER JOIN current_relations ON current_relations.id=current_relation_members.id", :conditions => [ "visible = 1 AND member_type='relation' and member_id=?", params[:id]]) - render :text => "", :status => :precondition_failed - else - relation.user_id = @user.id - relation.tags = [] - relation.members = [] - relation.visible = false - relation.save_with_history! - - render :nothing => true - end + new_relation = Relation.from_xml(request.raw_post) + if new_relation and new_relation.id == relation.id + relation.delete_with_history!(new_relation, @user) + render :text => relation.version.to_s, :content_type => "text/plain" else - render :text => "", :status => :gone + render :nothing => true, :status => :bad_request end + rescue OSM::APIError => ex + render ex.render_opts rescue ActiveRecord::RecordNotFound render :nothing => true, :status => :not_found - rescue - render :nothing => true, :status => :internal_server_error end end @@ -115,12 +97,12 @@ class RelationController < ApplicationController # first collect nodes, ways, and relations referenced by this relation. ways = Way.find_by_sql("select w.* from current_ways w,current_relation_members rm where "+ - "rm.member_type='way' and rm.member_id=w.id and rm.id=#{relation.id}"); + "rm.member_type='Way' and rm.member_id=w.id and rm.id=#{relation.id}"); nodes = Node.find_by_sql("select n.* from current_nodes n,current_relation_members rm where "+ - "rm.member_type='node' and rm.member_id=n.id and rm.id=#{relation.id}"); + "rm.member_type='Node' and rm.member_id=n.id and rm.id=#{relation.id}"); # note query is built to exclude self just in case. relations = Relation.find_by_sql("select r.* from current_relations r,current_relation_members rm where "+ - "rm.member_type='relation' and rm.member_id=r.id and rm.id=#{relation.id} and r.id<>rm.id"); + "rm.member_type='Relation' and rm.member_id=r.id and rm.id=#{relation.id} and r.id<>rm.id"); # now additionally collect nodes referenced by ways. Note how we recursively # evaluate ways but NOT relations. @@ -160,8 +142,7 @@ class RelationController < ApplicationController render :text => doc.to_s, :content_type => "text/xml" else - - render :text => "", :status => :gone + render :nothing => true, :status => :gone end rescue ActiveRecord::RecordNotFound @@ -184,27 +165,29 @@ class RelationController < ApplicationController render :text => doc.to_s, :content_type => "text/xml" else - render :nothing => true, :status => :bad_request + render :text => "You need to supply a comma separated list of ids.", :status => :bad_request end + rescue ActiveRecord::RecordNotFound + render :text => "Could not find one of the relations", :status => :not_found end def relations_for_way - relations_for_object("way") + relations_for_object("Way") end def relations_for_node - relations_for_object("node") + relations_for_object("Node") end def relations_for_relation - relations_for_object("relation") + relations_for_object("Relation") end def relations_for_object(objtype) - relationids = RelationMember.find(:all, :conditions => ['member_type=? and member_id=?', objtype, params[:id]]).collect { |ws| ws.id }.uniq + relationids = RelationMember.find(:all, :conditions => ['member_type=? and member_id=?', objtype, params[:id]]).collect { |ws| ws.id[0] }.uniq doc = OSM::API.new.get_xml_doc Relation.find(relationids).each do |relation| - doc.root << relation.to_xml_node + doc.root << relation.to_xml_node if relation.visible end render :text => doc.to_s, :content_type => "text/xml" diff --git a/app/controllers/trace_controller.rb b/app/controllers/trace_controller.rb index 3942cb7fe..0603567c4 100644 --- a/app/controllers/trace_controller.rb +++ b/app/controllers/trace_controller.rb @@ -1,8 +1,8 @@ class TraceController < ApplicationController layout 'site' - before_filter :authorize_web - before_filter :require_user, :only => [:mine, :edit, :delete, :make_public] + before_filter :authorize_web + before_filter :require_user, :only => [:mine, :create, :edit, :delete, :make_public] before_filter :authorize, :only => [:api_details, :api_data, :api_create] before_filter :check_database_readable, :except => [:api_details, :api_data, :api_create] before_filter :check_database_writable, :only => [:create, :edit, :delete, :make_public] @@ -15,7 +15,7 @@ class TraceController < ApplicationController # from display name, pick up user id if one user's traces only display_name = params[:display_name] if target_user.nil? and !display_name.blank? - target_user = User.find(:first, :conditions => [ "visible = 1 and display_name = ?", display_name]) + target_user = User.find(:first, :conditions => [ "visible = ? and display_name = ?", true, display_name]) end # set title @@ -36,15 +36,15 @@ class TraceController < ApplicationController # 4 - user's traces, not logged in as that user = all user's public traces if target_user.nil? # all traces if @user - conditions = ["(gpx_files.public = 1 OR gpx_files.user_id = ?)", @user.id] #1 + conditions = ["(gpx_files.public = ? OR gpx_files.user_id = ?)", true, @user.id] #1 else - conditions = ["gpx_files.public = 1"] #2 + conditions = ["gpx_files.public = ?", true] #2 end else if @user and @user == target_user conditions = ["gpx_files.user_id = ?", @user.id] #3 (check vs user id, so no join + can't pick up non-public traces by changing name) else - conditions = ["gpx_files.public = 1 AND gpx_files.user_id = ?", target_user.id] #4 + conditions = ["gpx_files.public = ? AND gpx_files.user_id = ?", true, target_user.id] #4 end end @@ -55,7 +55,8 @@ class TraceController < ApplicationController conditions[0] += " AND gpx_files.id IN (#{files.join(',')})" end - conditions[0] += " AND gpx_files.visible = 1" + conditions[0] += " AND gpx_files.visible = ?" + conditions << true @trace_pages, @traces = paginate(:traces, :include => [:user, :tags], @@ -100,26 +101,28 @@ class TraceController < ApplicationController end def create - logger.info(params[:trace][:gpx_file].class.name) - if params[:trace][:gpx_file].respond_to?(:read) - do_create(params[:trace][:gpx_file], params[:trace][:tagstring], - params[:trace][:description], params[:trace][:public]) + if params[:trace] + logger.info(params[:trace][:gpx_file].class.name) + if params[:trace][:gpx_file].respond_to?(:read) + do_create(params[:trace][:gpx_file], params[:trace][:tagstring], + params[:trace][:description], params[:trace][:public]) - if @trace.id - logger.info("id is #{@trace.id}") - flash[:notice] = "Your GPX file has been uploaded and is awaiting insertion in to the database. This will usually happen within half an hour, and an email will be sent to you on completion." + if @trace.id + logger.info("id is #{@trace.id}") + flash[:notice] = "Your GPX file has been uploaded and is awaiting insertion in to the database. This will usually happen within half an hour, and an email will be sent to you on completion." - redirect_to :action => 'mine' + redirect_to :action => 'mine' + end + else + @trace = Trace.new({:name => "Dummy", + :tagstring => params[:trace][:tagstring], + :description => params[:trace][:description], + :public => params[:trace][:public], + :inserted => false, :user => @user, + :timestamp => Time.now.getutc}) + @trace.valid? + @trace.errors.add(:gpx_file, "can't be blank") end - else - @trace = Trace.new({:name => "Dummy", - :tagstring => params[:trace][:tagstring], - :description => params[:trace][:description], - :public => params[:trace][:public], - :inserted => false, :user => @user, - :timestamp => Time.now}) - @trace.valid? - @trace.errors.add(:gpx_file, "can't be blank") end end @@ -196,7 +199,7 @@ class TraceController < ApplicationController end def georss - conditions = ["gpx_files.public = 1"] + conditions = ["gpx_files.public = ?", true] if params[:display_name] conditions[0] += " AND users.display_name = ?" @@ -278,12 +281,20 @@ class TraceController < ApplicationController def api_create if request.post? - do_create(params[:file], params[:tags], params[:description], params[:public]) - - if @trace.id - render :text => @trace.id.to_s, :content_type => "text/plain" - elsif @trace.valid? - render :nothing => true, :status => :internal_server_error + tags = params[:tags] || "" + description = params[:description] || "" + pub = params[:public] || false + + if params[:file].respond_to?(:read) + do_create(params[:file], tags, description, pub) + + if @trace.id + render :text => @trace.id.to_s, :content_type => "text/plain" + elsif @trace.valid? + render :nothing => true, :status => :internal_server_error + else + render :nothing => true, :status => :bad_request + end else render :nothing => true, :status => :bad_request end @@ -313,7 +324,7 @@ private :public => public, :inserted => true, :user => @user, - :timestamp => Time.now + :timestamp => Time.now.getutc }) # Save the trace object @@ -328,6 +339,17 @@ private # Remove the file as we have failed to update the database FileUtils.rm_f(filename) end + + # Finally save whether the user marked the trace as being public + if @trace.public? + if @user.trace_public_default.nil? + @user.preferences.create(:k => "gps.trace.public", :v => "default") + end + else + pref = @user.trace_public_default + pref.destroy unless pref.nil? + end + end end diff --git a/app/controllers/user_controller.rb b/app/controllers/user_controller.rb index 1cd34900a..9544dd8a8 100644 --- a/app/controllers/user_controller.rb +++ b/app/controllers/user_controller.rb @@ -83,7 +83,7 @@ class UserController < ApplicationController def lost_password @title = 'lost password' if params[:user] and params[:user][:email] - user = User.find_by_email(params[:user][:email], :conditions => "visible = 1") + user = User.find_by_email(params[:user][:email], :conditions => {:visible => true}) if user token = user.tokens.create @@ -120,9 +120,21 @@ class UserController < ApplicationController def new @title = 'create account' + # The user is logged in already, so don't show them the signup page, instead + # send them to the home page + redirect_to :controller => 'site', :action => 'index' if session[:user] end def login + if session[:user] + # The user is logged in already, if the referer param exists, redirect them to that + if params[:referer] + redirect_to params[:referer] + else + redirect_to :controller => 'site', :action => 'index' + end + return + end @title = 'login' if params[:user] email_or_display_name = params[:user][:email] @@ -223,7 +235,7 @@ class UserController < ApplicationController end def view - @this_user = User.find_by_display_name(params[:display_name], :conditions => "visible = 1") + @this_user = User.find_by_display_name(params[:display_name], :conditions => {:visible => true}) if @this_user @title = @this_user.display_name @@ -236,7 +248,7 @@ class UserController < ApplicationController def make_friend if params[:display_name] name = params[:display_name] - new_friend = User.find_by_display_name(name, :conditions => "visible = 1") + new_friend = User.find_by_display_name(name, :conditions => {:visible => true}) friend = Friend.new friend.user_id = @user.id friend.friend_user_id = new_friend.id @@ -258,7 +270,7 @@ class UserController < ApplicationController def remove_friend if params[:display_name] name = params[:display_name] - friend = User.find_by_display_name(name, :conditions => "visible = 1") + friend = User.find_by_display_name(name, :conditions => {:visible => true}) if @user.is_friends_with?(friend) Friend.delete_all "user_id = #{@user.id} AND friend_user_id = #{friend.id}" flash[:notice] = "#{friend.display_name} was removed from your friends." diff --git a/app/controllers/user_preference_controller.rb b/app/controllers/user_preference_controller.rb index 3a48ee65e..59573047a 100644 --- a/app/controllers/user_preference_controller.rb +++ b/app/controllers/user_preference_controller.rb @@ -5,11 +5,9 @@ class UserPreferenceController < ApplicationController def read_one pref = UserPreference.find(@user.id, params[:preference_key]) - if pref - render :text => pref.v.to_s - else - render :text => 'OH NOES! PREF NOT FOUND!', :status => 404 - end + render :text => pref.v.to_s + rescue ActiveRecord::RecordNotFound => ex + render :text => 'OH NOES! PREF NOT FOUND!', :status => :not_found end def update_one @@ -32,6 +30,8 @@ class UserPreferenceController < ApplicationController UserPreference.delete(@user.id, params[:preference_key]) render :nothing => true + rescue ActiveRecord::RecordNotFound => ex + render :text => "param: #{params[:preference_key]} not found", :status => :not_found end # print out all the preferences as a big xml block @@ -54,46 +54,44 @@ class UserPreferenceController < ApplicationController def update begin p = XML::Parser.string(request.raw_post) - doc = p.parse - - prefs = [] - - keyhash = {} - - doc.find('//preferences/preference').each do |pt| - pref = UserPreference.new + rescue LibXML::XML::Error, ArgumentError => ex + raise OSM::APIBadXMLError.new("preferences", xml, ex.message) + end + doc = p.parse - unless keyhash[pt['k']].nil? # already have that key - render :text => 'OH NOES! CAN HAS UNIQUE KEYS?', :status => :not_acceptable - return - end + prefs = [] - keyhash[pt['k']] = 1 + keyhash = {} - pref.k = pt['k'] - pref.v = pt['v'] - pref.user_id = @user.id - prefs << pref - end + doc.find('//preferences/preference').each do |pt| + pref = UserPreference.new - if prefs.size > 150 - render :text => 'Too many preferences', :status => :request_entity_too_large - return + unless keyhash[pt['k']].nil? # already have that key + render :text => 'OH NOES! CAN HAS UNIQUE KEYS?', :status => :not_acceptable end - # kill the existing ones - UserPreference.delete_all(['user_id = ?', @user.id]) + keyhash[pt['k']] = 1 - # save the new ones - prefs.each do |pref| - pref.save! - end + pref.k = pt['k'] + pref.v = pt['v'] + pref.user_id = @user.id + prefs << pref + end - rescue Exception => ex - render :text => 'OH NOES! FAIL!: ' + ex.to_s, :status => :internal_server_error - return + if prefs.size > 150 + render :text => 'Too many preferences', :status => :request_entity_too_large end + # kill the existing ones + UserPreference.delete_all(['user_id = ?', @user.id]) + + # save the new ones + prefs.each do |pref| + pref.save! + end render :nothing => true + + rescue Exception => ex + render :text => 'OH NOES! FAIL!: ' + ex.to_s, :status => :internal_server_error end end diff --git a/app/controllers/way_controller.rb b/app/controllers/way_controller.rb index 9b93ec2b3..e28945dcd 100644 --- a/app/controllers/way_controller.rb +++ b/app/controllers/way_controller.rb @@ -8,23 +8,22 @@ class WayController < ApplicationController after_filter :compress_output def create - if request.put? - way = Way.from_xml(request.raw_post, true) - - if way - if !way.preconditions_ok? - render :text => "", :status => :precondition_failed - else - way.user_id = @user.id - way.save_with_history! + begin + if request.put? + way = Way.from_xml(request.raw_post, true) + if way + way.create_with_history @user render :text => way.id.to_s, :content_type => "text/plain" + else + render :nothing => true, :status => :bad_request end else - render :nothing => true, :status => :bad_request + render :nothing => true, :status => :method_not_allowed end - else - render :nothing => true, :status => :method_not_allowed + rescue OSM::APIError => ex + logger.warn request.raw_post + render ex.render_opts end end @@ -39,6 +38,8 @@ class WayController < ApplicationController else render :text => "", :status => :gone end + rescue OSM::APIError => ex + render ex.render_opts rescue ActiveRecord::RecordNotFound render :nothing => true, :status => :not_found end @@ -50,20 +51,14 @@ class WayController < ApplicationController new_way = Way.from_xml(request.raw_post) if new_way and new_way.id == way.id - if !new_way.preconditions_ok? - render :text => "", :status => :precondition_failed - else - way.user_id = @user.id - way.tags = new_way.tags - way.nds = new_way.nds - way.visible = true - way.save_with_history! - - render :nothing => true - end + way.update_from(new_way, @user) + render :text => way.version.to_s, :content_type => "text/plain" else render :nothing => true, :status => :bad_request end + rescue OSM::APIError => ex + logger.warn request.raw_post + render ex.render_opts rescue ActiveRecord::RecordNotFound render :nothing => true, :status => :not_found end @@ -73,14 +68,16 @@ class WayController < ApplicationController def delete begin way = Way.find(params[:id]) - way.delete_with_relations_and_history(@user) - - # if we get here, all is fine, otherwise something will catch below. - render :nothing => true - rescue OSM::APIAlreadyDeletedError - render :text => "", :status => :gone - rescue OSM::APIPreconditionFailedError - render :text => "", :status => :precondition_failed + new_way = Way.from_xml(request.raw_post) + + if new_way and new_way.id == way.id + way.delete_with_history!(new_way, @user) + render :text => way.version.to_s, :content_type => "text/plain" + else + render :nothing => true, :status => :bad_request + end + rescue OSM::APIError => ex + render ex.render_opts rescue ActiveRecord::RecordNotFound render :nothing => true, :status => :not_found end @@ -92,7 +89,7 @@ class WayController < ApplicationController if way.visible nd_ids = way.nds + [-1] - nodes = Node.find(:all, :conditions => "visible = 1 AND id IN (#{nd_ids.join(',')})") + nodes = Node.find(:all, :conditions => ["visible = ? AND id IN (#{nd_ids.join(',')})", true]) # Render doc = OSM::API.new.get_xml_doc @@ -130,13 +127,19 @@ class WayController < ApplicationController end end + ## + # returns all the ways which are currently using the node given in the + # :id parameter. note that this used to return deleted ways as well, but + # this seemed not to be the expected behaviour, so it was removed. def ways_for_node - wayids = WayNode.find(:all, :conditions => ['node_id = ?', params[:id]]).collect { |ws| ws.id[0] }.uniq + wayids = WayNode.find(:all, + :conditions => ['node_id = ?', params[:id]] + ).collect { |ws| ws.id[0] }.uniq doc = OSM::API.new.get_xml_doc Way.find(wayids).each do |way| - doc.root << way.to_xml_node + doc.root << way.to_xml_node if way.visible end render :text => doc.to_s, :content_type => "text/xml" diff --git a/app/helpers/browse_helper.rb b/app/helpers/browse_helper.rb index c86ad5b71..34302a8af 100644 --- a/app/helpers/browse_helper.rb +++ b/app/helpers/browse_helper.rb @@ -1,2 +1,5 @@ module BrowseHelper + def link_to_page(page, page_param) + return link_to(page, page_param => page) + end end diff --git a/app/models/acl.rb b/app/models/acl.rb index 5fb99b9e5..3ff19d35f 100644 --- a/app/models/acl.rb +++ b/app/models/acl.rb @@ -1,13 +1,23 @@ class Acl < ActiveRecord::Base def self.find_by_address(address, options) - self.with_scope(:find => {:conditions => ["inet_aton(?) & netmask = address", address]}) do + self.with_scope(:find => {:conditions => ["#{inet_aton} & netmask = address", address]}) do return self.find(:first, options) end end def self.find_all_by_address(address, options) - self.with_scope(:find => {:conditions => ["inet_aton(?) & netmask = address", address]}) do + self.with_scope(:find => {:conditions => ["#{inet_aton} & netmask = address", address]}) do return self.find(:all, options) end end + +private + + def self.inet_aton + if self.connection.adapter_name == "MySQL" + "inet_aton(?)" + else + "?" + end + end end diff --git a/app/models/changeset.rb b/app/models/changeset.rb new file mode 100644 index 000000000..fa2d556b1 --- /dev/null +++ b/app/models/changeset.rb @@ -0,0 +1,241 @@ +class Changeset < ActiveRecord::Base + require 'xml/libxml' + + belongs_to :user + + has_many :changeset_tags, :foreign_key => 'id' + + has_many :nodes + has_many :ways + has_many :relations + has_many :old_nodes + has_many :old_ways + has_many :old_relations + + validates_presence_of :id, :on => :update + validates_presence_of :user_id, :created_at, :closed_at, :num_changes + validates_uniqueness_of :id + validates_numericality_of :id, :on => :update, :integer_only => true + validates_numericality_of :min_lat, :max_lat, :min_lon, :max_lat, :allow_nil => true, :integer_only => true + validates_numericality_of :user_id, :integer_only => true + validates_numericality_of :num_changes, :integer_only => true, :greater_than_or_equal_to => 0 + validates_associated :user + + # over-expansion factor to use when updating the bounding box + EXPAND = 0.1 + + # maximum number of elements allowed in a changeset + MAX_ELEMENTS = 50000 + + # maximum time a changeset is allowed to be open for. + MAX_TIME_OPEN = 1.day + + # idle timeout increment, one hour seems reasonable. + IDLE_TIMEOUT = 1.hour + + # Use a method like this, so that we can easily change how we + # determine whether a changeset is open, without breaking code in at + # least 6 controllers + def is_open? + # a changeset is open (that is, it will accept further changes) when + # it has not yet run out of time and its capacity is small enough. + # note that this may not be a hard limit - due to timing changes and + # concurrency it is possible that some changesets may be slightly + # longer than strictly allowed or have slightly more changes in them. + return ((closed_at > Time.now.getutc) and (num_changes <= MAX_ELEMENTS)) + end + + def set_closed_time_now + if is_open? + self.closed_at = Time.now.getutc + end + end + + def self.from_xml(xml, create=false) + begin + p = XML::Parser.string(xml) + doc = p.parse + + cs = Changeset.new + + doc.find('//osm/changeset').each do |pt| + if create + cs.created_at = Time.now.getutc + # initial close time is 1h ahead, but will be increased on each + # modification. + cs.closed_at = cs.created_at + IDLE_TIMEOUT + # initially we have no changes in a changeset + cs.num_changes = 0 + end + + pt.find('tag').each do |tag| + cs.add_tag_keyval(tag['k'], tag['v']) + end + end + rescue Exception => ex + cs = nil + end + + return cs + end + + ## + # returns the bounding box of the changeset. it is possible that some + # or all of the values will be nil, indicating that they are undefined. + def bbox + @bbox ||= [ min_lon, min_lat, max_lon, max_lat ] + end + + def has_valid_bbox? + not bbox.include? nil + end + + ## + # expand the bounding box to include the given bounding box. also, + # expand a little bit more in the direction of the expansion, so that + # further expansions may be unnecessary. this is an optimisation + # suggested on the wiki page by kleptog. + def update_bbox!(array) + # ensure that bbox is cached and has no nils in it. if there are any + # nils, just use the bounding box update to write over them. + @bbox = bbox.zip(array).collect { |a, b| a.nil? ? b : a } + + # FIXME - this looks nasty and violates DRY... is there any prettier + # way to do this? + @bbox[0] = array[0] + EXPAND * (@bbox[0] - @bbox[2]) if array[0] < @bbox[0] + @bbox[1] = array[1] + EXPAND * (@bbox[1] - @bbox[3]) if array[1] < @bbox[1] + @bbox[2] = array[2] + EXPAND * (@bbox[2] - @bbox[0]) if array[2] > @bbox[2] + @bbox[3] = array[3] + EXPAND * (@bbox[3] - @bbox[1]) if array[3] > @bbox[3] + + # update active record. rails 2.1's dirty handling should take care of + # whether this object needs saving or not. + self.min_lon, self.min_lat, self.max_lon, self.max_lat = @bbox + end + + ## + # the number of elements is also passed in so that we can ensure that + # a single changeset doesn't contain too many elements. this, of course, + # destroys the optimisation described in the bbox method above. + def add_changes!(elements) + self.num_changes += elements + end + + def tags_as_hash + return tags + end + + def tags + unless @tags + @tags = {} + self.changeset_tags.each do |tag| + @tags[tag.k] = tag.v + end + end + @tags + end + + def tags=(t) + @tags = t + end + + def add_tag_keyval(k, v) + @tags = Hash.new unless @tags + @tags[k] = v + end + + def save_with_tags! + t = Time.now.getutc + + # do the changeset update and the changeset tags update in the + # same transaction to ensure consistency. + Changeset.transaction do + # set the auto-close time to be one hour in the future unless + # that would make it more than 24h long, in which case clip to + # 24h, as this has been decided is a reasonable time limit. + if (closed_at - created_at) > (MAX_TIME_OPEN - IDLE_TIMEOUT) + self.closed_at = created_at + MAX_TIME_OPEN + else + self.closed_at = Time.now.getutc + IDLE_TIMEOUT + end + self.save! + + tags = self.tags + ChangesetTag.delete_all(['id = ?', self.id]) + + tags.each do |k,v| + tag = ChangesetTag.new + tag.k = k + tag.v = v + tag.id = self.id + tag.save! + end + end + end + + def to_xml + doc = OSM::API.new.get_xml_doc + doc.root << to_xml_node() + return doc + end + + def to_xml_node(user_display_name_cache = nil) + el1 = XML::Node.new 'changeset' + el1['id'] = self.id.to_s + + user_display_name_cache = {} if user_display_name_cache.nil? + + if user_display_name_cache and user_display_name_cache.key?(self.user_id) + # use the cache if available + elsif self.user.data_public? + user_display_name_cache[self.user_id] = self.user.display_name + else + user_display_name_cache[self.user_id] = nil + end + + el1['user'] = user_display_name_cache[self.user_id] unless user_display_name_cache[self.user_id].nil? + el1['uid'] = self.user_id.to_s if self.user.data_public? + + self.tags.each do |k,v| + el2 = XML::Node.new('tag') + el2['k'] = k.to_s + el2['v'] = v.to_s + el1 << el2 + end + + el1['created_at'] = self.created_at.xmlschema + el1['closed_at'] = self.closed_at.xmlschema unless is_open? + el1['open'] = is_open?.to_s + + el1['min_lon'] = (bbox[0].to_f / GeoRecord::SCALE).to_s unless bbox[0].nil? + el1['min_lat'] = (bbox[1].to_f / GeoRecord::SCALE).to_s unless bbox[1].nil? + el1['max_lon'] = (bbox[2].to_f / GeoRecord::SCALE).to_s unless bbox[2].nil? + el1['max_lat'] = (bbox[3].to_f / GeoRecord::SCALE).to_s unless bbox[3].nil? + + # NOTE: changesets don't include the XML of the changes within them, + # they are just structures for tagging. to get the osmChange of a + # changeset, see the download method of the controller. + + return el1 + end + + ## + # update this instance from another instance given and the user who is + # doing the updating. note that this method is not for updating the + # bounding box, only the tags of the changeset. + def update_from(other, user) + # ensure that only the user who opened the changeset may modify it. + unless user.id == self.user_id + raise OSM::APIUserChangesetMismatchError.new + end + + # can't change a closed changeset + unless is_open? + raise OSM::APIChangesetAlreadyClosedError.new(self) + end + + # copy the other's tags + self.tags = other.tags + + save_with_tags! + end +end diff --git a/app/models/changeset_tag.rb b/app/models/changeset_tag.rb new file mode 100644 index 000000000..6a414a0fc --- /dev/null +++ b/app/models/changeset_tag.rb @@ -0,0 +1,8 @@ +class ChangesetTag < ActiveRecord::Base + belongs_to :changeset, :foreign_key => 'id' + + validates_presence_of :id + validates_length_of :k, :v, :maximum => 255, :allow_blank => true + validates_uniqueness_of :id, :scope => :k + validates_numericality_of :id, :only_integer => true +end diff --git a/app/models/diary_entry.rb b/app/models/diary_entry.rb index dd1f9882a..46d96fec7 100644 --- a/app/models/diary_entry.rb +++ b/app/models/diary_entry.rb @@ -1,11 +1,15 @@ class DiaryEntry < ActiveRecord::Base belongs_to :user has_many :diary_comments, :include => :user, - :conditions => "users.visible = 1", + :conditions => ["users.visible = ?", true], :order => "diary_comments.id" validates_presence_of :title, :body - validates_numericality_of :latitude, :allow_nil => true - validates_numericality_of :longitude, :allow_nil => true + validates_length_of :title, :within => 1..255 + validates_length_of :language, :within => 2..3, :allow_nil => true + validates_numericality_of :latitude, :allow_nil => true, + :greater_than_or_equal_to => -90, :less_than_or_equal_to => 90 + validates_numericality_of :longitude, :allow_nil => true, + :greater_than_or_equal_to => -180, :less_than_or_equal_to => 180 validates_associated :user end diff --git a/app/models/message.rb b/app/models/message.rb index 97e411192..464c55028 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -1,8 +1,12 @@ +require 'validators' + class Message < ActiveRecord::Base belongs_to :sender, :class_name => "User", :foreign_key => :from_user_id belongs_to :recipient, :class_name => "User", :foreign_key => :to_user_id - validates_presence_of :title, :body, :sent_on + validates_presence_of :title, :body, :sent_on, :sender, :recipient + validates_length_of :title, :within => 1..255 validates_inclusion_of :message_read, :in => [ true, false ] validates_associated :sender, :recipient + validates_as_utf8 :title end diff --git a/app/models/node.rb b/app/models/node.rb index af88a117d..1392fc650 100644 --- a/app/models/node.rb +++ b/app/models/node.rb @@ -2,27 +2,34 @@ class Node < ActiveRecord::Base require 'xml/libxml' include GeoRecord + include ConsistencyValidations set_table_name 'current_nodes' - - validates_presence_of :user_id, :timestamp - validates_inclusion_of :visible, :in => [ true, false ] - validates_numericality_of :latitude, :longitude - validate :validate_position - belongs_to :user + belongs_to :changeset has_many :old_nodes, :foreign_key => :id has_many :way_nodes has_many :ways, :through => :way_nodes + has_many :node_tags, :foreign_key => :id + has_many :old_way_nodes has_many :ways_via_history, :class_name=> "Way", :through => :old_way_nodes, :source => :way has_many :containing_relation_members, :class_name => "RelationMember", :as => :member has_many :containing_relations, :class_name => "Relation", :through => :containing_relation_members, :source => :relation, :extend => ObjectFinder + validates_presence_of :id, :on => :update + validates_presence_of :timestamp,:version, :changeset_id + validates_uniqueness_of :id + validates_inclusion_of :visible, :in => [ true, false ] + validates_numericality_of :latitude, :longitude, :changeset_id, :version, :integer_only => true + validates_numericality_of :id, :on => :update, :integer_only => true + validate :validate_position + validates_associated :changeset + # Sanity check the latitude and longitude and add an error if it's broken def validate_position errors.add_to_base("Node is not in the world") unless in_world? @@ -50,7 +57,7 @@ class Node < ActiveRecord::Base #conditions = keys.join(' AND ') find_by_area(min_lat, min_lon, max_lat, max_lon, - :conditions => 'visible = 1', + :conditions => {:visible => true}, :limit => APP_CONFIG['max_number_of_nodes']+1) end @@ -59,83 +66,150 @@ class Node < ActiveRecord::Base begin p = XML::Parser.string(xml) doc = p.parse - - node = Node.new doc.find('//osm/node').each do |pt| - node.lat = pt['lat'].to_f - node.lon = pt['lon'].to_f + return Node.from_xml_node(pt, create) + end + rescue LibXML::XML::Error, ArgumentError => ex + raise OSM::APIBadXMLError.new("node", xml, ex.message) + end + end + + def self.from_xml_node(pt, create=false) + node = Node.new + + raise OSM::APIBadXMLError.new("node", pt, "lat missing") if pt['lat'].nil? + raise OSM::APIBadXMLError.new("node", pt, "lon missing") if pt['lon'].nil? + node.lat = pt['lat'].to_f + node.lon = pt['lon'].to_f + raise OSM::APIBadXMLError.new("node", pt, "changeset id missing") if pt['changeset'].nil? + node.changeset_id = pt['changeset'].to_i - return nil unless node.in_world? + raise OSM::APIBadUserInput.new("The node is outside this world") unless node.in_world? - unless create - if pt['id'] != '0' - node.id = pt['id'].to_i - end - end + # version must be present unless creating + raise OSM::APIBadXMLError.new("node", pt, "Version is required when updating") unless create or not pt['version'].nil? + node.version = create ? 0 : pt['version'].to_i - node.visible = pt['visible'] and pt['visible'] == 'true' + unless create + if pt['id'] != '0' + node.id = pt['id'].to_i + end + end - if create - node.timestamp = Time.now - else - if pt['timestamp'] - node.timestamp = Time.parse(pt['timestamp']) - end - end + # visible if it says it is, or as the default if the attribute + # is missing. + # Don't need to set the visibility, when it is set explicitly in the create/update/delete + #node.visible = pt['visible'].nil? or pt['visible'] == 'true' - tags = [] + # We don't care about the time, as it is explicitly set on create/update/delete - pt.find('tag').each do |tag| - tags << [tag['k'],tag['v']] - end + tags = [] - node.tags = Tags.join(tags) - end - rescue - node = nil + pt.find('tag').each do |tag| + node.add_tag_key_val(tag['k'],tag['v']) end return node end - # Save this node with the appropriate OldNode object to represent it's history. - def save_with_history! + ## + # the bounding box around a node, which is used for determining the changeset's + # bounding box + def bbox + [ longitude, latitude, longitude, latitude ] + end + + # Should probably be renamed delete_from to come in line with update + def delete_with_history!(new_node, user) + unless self.visible + raise OSM::APIAlreadyDeletedError.new + end + + # need to start the transaction here, so that the database can + # provide repeatable reads for the used-by checks. this means it + # shouldn't be possible to get race conditions. Node.transaction do - self.timestamp = Time.now - self.save! - old_node = OldNode.from_node(self) - old_node.save! + check_consistency(self, new_node, user) + if WayNode.find(:first, :joins => "INNER JOIN current_ways ON current_ways.id = current_way_nodes.id", :conditions => [ "current_ways.visible = ? AND current_way_nodes.node_id = ?", true, self.id ]) + raise OSM::APIPreconditionFailedError.new + elsif RelationMember.find(:first, :joins => "INNER JOIN current_relations ON current_relations.id=current_relation_members.id", :conditions => [ "visible = ? AND member_type='Node' and member_id=? ", true, self.id]) + raise OSM::APIPreconditionFailedError.new + else + self.changeset_id = new_node.changeset_id + self.visible = false + + # update the changeset with the deleted position + changeset.update_bbox!(bbox) + + save_with_history! + end end end - # Turn this Node in to a complete OSM XML object with wrapper + def update_from(new_node, user) + check_consistency(self, new_node, user) + + # update changeset first + self.changeset_id = new_node.changeset_id + self.changeset = new_node.changeset + + # update changeset bbox with *old* position first + changeset.update_bbox!(bbox); + + # FIXME logic needs to be double checked + self.latitude = new_node.latitude + self.longitude = new_node.longitude + self.tags = new_node.tags + self.visible = true + + # update changeset bbox with *new* position + changeset.update_bbox!(bbox); + + save_with_history! + end + + def create_with_history(user) + check_create_consistency(self, user) + self.version = 0 + self.visible = true + + # update the changeset to include the new location + changeset.update_bbox!(bbox) + + save_with_history! + end + def to_xml doc = OSM::API.new.get_xml_doc doc.root << to_xml_node() return doc end - # Turn this Node in to an XML Node without the wrapper. def to_xml_node(user_display_name_cache = nil) el1 = XML::Node.new 'node' el1['id'] = self.id.to_s el1['lat'] = self.lat.to_s el1['lon'] = self.lon.to_s - + el1['version'] = self.version.to_s + el1['changeset'] = self.changeset_id.to_s + user_display_name_cache = {} if user_display_name_cache.nil? - if user_display_name_cache and user_display_name_cache.key?(self.user_id) + if user_display_name_cache and user_display_name_cache.key?(self.changeset.user_id) # use the cache if available - elsif self.user.data_public? - user_display_name_cache[self.user_id] = self.user.display_name + elsif self.changeset.user.data_public? + user_display_name_cache[self.changeset.user_id] = self.changeset.user.display_name else - user_display_name_cache[self.user_id] = nil + user_display_name_cache[self.changeset.user_id] = nil end - el1['user'] = user_display_name_cache[self.user_id] unless user_display_name_cache[self.user_id].nil? + if not user_display_name_cache[self.changeset.user_id].nil? + el1['user'] = user_display_name_cache[self.changeset.user_id] + el1['uid'] = self.changeset.user_id.to_s + end - Tags.split(self.tags) do |k,v| + self.tags.each do |k,v| el2 = XML::Node.new('tag') el2['k'] = k.to_s el2['v'] = v.to_s @@ -147,12 +221,79 @@ class Node < ActiveRecord::Base return el1 end - # Return the node's tags as a Hash of keys and their values def tags_as_hash - hash = {} - Tags.split(self.tags) do |k,v| - hash[k] = v + return tags + end + + def tags + unless @tags + @tags = {} + self.node_tags.each do |tag| + @tags[tag.k] = tag.v + end + end + @tags + end + + def tags=(t) + @tags = t + end + + def add_tag_key_val(k,v) + @tags = Hash.new unless @tags + + # duplicate tags are now forbidden, so we can't allow values + # in the hash to be overwritten. + raise OSM::APIDuplicateTagsError.new("node", self.id, k) if @tags.include? k + + @tags[k] = v + end + + ## + # are the preconditions OK? this is mainly here to keep the duck + # typing interface the same between nodes, ways and relations. + def preconditions_ok? + in_world? + end + + ## + # dummy method to make the interfaces of node, way and relation + # more consistent. + def fix_placeholders!(id_map) + # nodes don't refer to anything, so there is nothing to do here + end + + private + + def save_with_history! + t = Time.now.getutc + Node.transaction do + self.version += 1 + self.timestamp = t + self.save! + + # Create a NodeTag + tags = self.tags + NodeTag.delete_all(['id = ?', self.id]) + tags.each do |k,v| + tag = NodeTag.new + tag.k = k + tag.v = v + tag.id = self.id + tag.save! + end + + # Create an OldNode + old_node = OldNode.from_node(self) + old_node.timestamp = t + old_node.save_with_dependencies! + + # tell the changeset we updated one element only + changeset.add_changes! 1 + + # save the changeset in case of bounding box updates + changeset.save! end - hash end + end diff --git a/app/models/node_tag.rb b/app/models/node_tag.rb new file mode 100644 index 000000000..494260112 --- /dev/null +++ b/app/models/node_tag.rb @@ -0,0 +1,10 @@ +class NodeTag < ActiveRecord::Base + set_table_name 'current_node_tags' + + belongs_to :node, :foreign_key => 'id' + + validates_presence_of :id + validates_length_of :k, :v, :maximum => 255, :allow_blank => true + validates_uniqueness_of :id, :scope => :k + validates_numericality_of :id, :only_integer => true +end diff --git a/app/models/old_node.rb b/app/models/old_node.rb index 76eab8427..be115c53e 100644 --- a/app/models/old_node.rb +++ b/app/models/old_node.rb @@ -1,25 +1,21 @@ class OldNode < ActiveRecord::Base include GeoRecord + include ConsistencyValidations set_table_name 'nodes' - validates_presence_of :user_id, :timestamp + validates_presence_of :changeset_id, :timestamp validates_inclusion_of :visible, :in => [ true, false ] validates_numericality_of :latitude, :longitude validate :validate_position + validates_associated :changeset - belongs_to :user + belongs_to :changeset def validate_position errors.add_to_base("Node is not in the world") unless in_world? end - def in_world? - return false if self.lat < -90 or self.lat > 90 - return false if self.lon < -180 or self.lon > 180 - return true - end - def self.from_node(node) old_node = OldNode.new old_node.latitude = node.latitude @@ -27,19 +23,30 @@ class OldNode < ActiveRecord::Base old_node.visible = node.visible old_node.tags = node.tags old_node.timestamp = node.timestamp - old_node.user_id = node.user_id + old_node.changeset_id = node.changeset_id old_node.id = node.id + old_node.version = node.version return old_node end + + def to_xml + doc = OSM::API.new.get_xml_doc + doc.root << to_xml_node() + return doc + end def to_xml_node el1 = XML::Node.new 'node' el1['id'] = self.id.to_s el1['lat'] = self.lat.to_s el1['lon'] = self.lon.to_s - el1['user'] = self.user.display_name if self.user.data_public? + el1['changeset'] = self.changeset.id.to_s + if self.changeset.user.data_public? + el1['user'] = self.changeset.user.display_name + el1['uid'] = self.changeset.user.id.to_s + end - Tags.split(self.tags) do |k,v| + self.tags.each do |k,v| el2 = XML::Node.new('tag') el2['k'] = k.to_s el2['v'] = v.to_s @@ -48,24 +55,54 @@ class OldNode < ActiveRecord::Base el1['visible'] = self.visible.to_s el1['timestamp'] = self.timestamp.xmlschema + el1['version'] = self.version.to_s return el1 end - - def tags_as_hash - hash = {} - Tags.split(self.tags) do |k,v| - hash[k] = v + + def save_with_dependencies! + save! + #not sure whats going on here + clear_aggregation_cache + clear_association_cache + #ok from here + @attributes.update(OldNode.find(:first, :conditions => ['id = ? AND timestamp = ? AND version = ?', self.id, self.timestamp, self.version]).instance_variable_get('@attributes')) + + self.tags.each do |k,v| + tag = OldNodeTag.new + tag.k = k + tag.v = v + tag.id = self.id + tag.version = self.version + tag.save! end - hash end - # Pretend we're not in any ways - def ways - return [] + def tags + unless @tags + @tags = Hash.new + OldNodeTag.find(:all, :conditions => ["id = ? AND version = ?", self.id, self.version]).each do |tag| + @tags[tag.k] = tag.v + end + end + @tags = Hash.new unless @tags + @tags end - # Pretend we're not in any relations - def containing_relation_members - return [] + def tags=(t) + @tags = t end + + def tags_as_hash + return self.tags + end + + # Pretend we're not in any ways + def ways + return [] + end + + # Pretend we're not in any relations + def containing_relation_members + return [] + end end diff --git a/app/models/old_node_tag.rb b/app/models/old_node_tag.rb new file mode 100644 index 000000000..3fd4bf86b --- /dev/null +++ b/app/models/old_node_tag.rb @@ -0,0 +1,10 @@ +class OldNodeTag < ActiveRecord::Base + set_table_name 'node_tags' + + belongs_to :user + + validates_presence_of :id, :version + validates_length_of :k, :v, :maximum => 255, :allow_blank => true + validates_uniqueness_of :id, :scope => [:k, :version] + validates_numericality_of :id, :version, :only_integer => true +end diff --git a/app/models/old_relation.rb b/app/models/old_relation.rb index bac03c4d2..b2fdf926e 100644 --- a/app/models/old_relation.rb +++ b/app/models/old_relation.rb @@ -1,14 +1,19 @@ class OldRelation < ActiveRecord::Base + include ConsistencyValidations + set_table_name 'relations' - belongs_to :user + belongs_to :changeset + + validates_associated :changeset def self.from_relation(relation) old_relation = OldRelation.new old_relation.visible = relation.visible - old_relation.user_id = relation.user_id + old_relation.changeset_id = relation.changeset_id old_relation.timestamp = relation.timestamp old_relation.id = relation.id + old_relation.version = relation.version old_relation.members = relation.members old_relation.tags = relation.tags return old_relation @@ -33,14 +38,12 @@ class OldRelation < ActiveRecord::Base tag.save! end - i = 1 - self.members.each do |m| + self.members.each_with_index do |m,i| member = OldRelationMember.new - member.id = self.id - member.member_type = m[0] + member.id = [self.id, self.version, i] + member.member_type = m[0].classify member.member_id = m[1] member.member_role = m[2] - member.version = self.version member.save! end end @@ -48,7 +51,7 @@ class OldRelation < ActiveRecord::Base def members unless @members @members = Array.new - OldRelationMember.find(:all, :conditions => ["id = ? AND version = ?", self.id, self.version]).each do |m| + OldRelationMember.find(:all, :conditions => ["id = ? AND version = ?", self.id, self.version], :order => "sequence_id").each do |m| @members += [[m.type,m.id,m.role]] end end @@ -85,16 +88,27 @@ class OldRelation < ActiveRecord::Base OldRelationTag.find(:all, :conditions => ['id = ? AND version = ?', self.id, self.version]) end + def to_xml + doc = OSM::API.new.get_xml_doc + doc.root << to_xml_node() + return doc + end + def to_xml_node el1 = XML::Node.new 'relation' el1['id'] = self.id.to_s el1['visible'] = self.visible.to_s el1['timestamp'] = self.timestamp.xmlschema - el1['user'] = self.user.display_name if self.user.data_public? + if self.changeset.user.data_public? + el1['user'] = self.changeset.user.display_name + el1['uid'] = self.changeset.user.id.to_s + end + el1['version'] = self.version.to_s + el1['changeset'] = self.changeset_id.to_s self.old_members.each do |member| e = XML::Node.new 'member' - e['type'] = member.member_type.to_s + e['type'] = member.member_type.to_s.downcase e['ref'] = member.member_id.to_s # "id" is considered uncool here as it should be unique in XML e['role'] = member.member_role.to_s el1 << e diff --git a/app/models/old_relation_member.rb b/app/models/old_relation_member.rb index d8b685854..f0294d339 100644 --- a/app/models/old_relation_member.rb +++ b/app/models/old_relation_member.rb @@ -1,3 +1,6 @@ class OldRelationMember < ActiveRecord::Base set_table_name 'relation_members' + + set_primary_keys :id, :version, :sequence_id + belongs_to :relation, :foreign_key=> :id end diff --git a/app/models/old_relation_tag.rb b/app/models/old_relation_tag.rb index 7ce6f694e..0fcb11326 100644 --- a/app/models/old_relation_tag.rb +++ b/app/models/old_relation_tag.rb @@ -1,3 +1,10 @@ class OldRelationTag < ActiveRecord::Base set_table_name 'relation_tags' + + belongs_to :old_relation, :foreign_key => [:id, :version] + + validates_presence_of :id, :version + validates_length_of :k, :v, :maximum => 255, :allow_blank => true + validates_uniqueness_of :id, :scope => [:k, :version] + validates_numericality_of :id, :version, :only_integer => true end diff --git a/app/models/old_way.rb b/app/models/old_way.rb index 63265d6bf..425478a5b 100644 --- a/app/models/old_way.rb +++ b/app/models/old_way.rb @@ -1,14 +1,19 @@ class OldWay < ActiveRecord::Base + include ConsistencyValidations + set_table_name 'ways' - belongs_to :user + belongs_to :changeset + validates_associated :changeset + def self.from_way(way) old_way = OldWay.new old_way.visible = way.visible - old_way.user_id = way.user_id + old_way.changeset_id = way.changeset_id old_way.timestamp = way.timestamp old_way.id = way.id + old_way.version = way.version old_way.nds = way.nds old_way.tags = way.tags return old_way @@ -93,7 +98,12 @@ class OldWay < ActiveRecord::Base el1['id'] = self.id.to_s el1['visible'] = self.visible.to_s el1['timestamp'] = self.timestamp.xmlschema - el1['user'] = self.user.display_name if self.user.data_public? + if self.changeset.user.data_public? + el1['user'] = self.changeset.user.display_name + el1['uid'] = self.changeset.user.id.to_s + end + el1['version'] = self.version.to_s + el1['changeset'] = self.changeset.id.to_s self.old_nodes.each do |nd| # FIXME need to make sure they come back in the right order e = XML::Node.new 'nd' @@ -114,27 +124,29 @@ class OldWay < ActiveRecord::Base # For get_nodes_undelete, uses same nodes, even if they've moved since # For get_nodes_revert, allocates new ids # Currently returns Potlatch-style array - + # where [5] indicates whether latest version is usable as is (boolean) + # (i.e. is it visible? are we actually reverting to an earlier version?) + def get_nodes_undelete points = [] self.nds.each do |n| node=Node.find(n) - points << [node.lon, node.lat, n, node.visible ? 1 : 0, node.tags_as_hash] + points << [node.lon, node.lat, n, node.version, node.tags_as_hash, node.visible] end points end - def get_nodes_revert + def get_nodes_revert(timestamp) points=[] self.nds.each do |n| - oldnode=OldNode.find(:first, :conditions=>['id=? AND timestamp<=?',n,self.timestamp], :order=>"timestamp DESC") + oldnode=OldNode.find(:first, :conditions=>['id=? AND timestamp<=?',n,timestamp], :order=>"timestamp DESC") curnode=Node.find(n) - id=n; v=curnode.visible ? 1 : 0 + id=n; reuse=curnode.visible if oldnode.lat!=curnode.lat or oldnode.lon!=curnode.lon or oldnode.tags!=curnode.tags then # node has changed: if it's in other ways, give it a new id - if curnode.ways-[self.id] then id=-1; v=nil end + if curnode.ways-[self.id] then id=-1; reuse=false end end - points << [oldnode.lon, oldnode.lat, id, v, oldnode.tags_as_hash] + points << [oldnode.lon, oldnode.lat, id, curnode.version, oldnode.tags_as_hash, reuse] end points end diff --git a/app/models/old_way_tag.rb b/app/models/old_way_tag.rb index b02fd45b9..801532dba 100644 --- a/app/models/old_way_tag.rb +++ b/app/models/old_way_tag.rb @@ -1,6 +1,10 @@ class OldWayTag < ActiveRecord::Base - belongs_to :user - set_table_name 'way_tags' + belongs_to :old_way, :foreign_key => [:id, :version] + + validates_presence_of :id + validates_length_of :k, :v, :maximum => 255, :allow_blank => true + validates_uniqueness_of :id, :scope => [:k, :version] + validates_numericality_of :id, :version, :only_integer => true end diff --git a/app/models/relation.rb b/app/models/relation.rb index d9dba303f..36d6943d9 100644 --- a/app/models/relation.rb +++ b/app/models/relation.rb @@ -1,51 +1,83 @@ class Relation < ActiveRecord::Base require 'xml/libxml' + include ConsistencyValidations + set_table_name 'current_relations' - belongs_to :user + belongs_to :changeset has_many :old_relations, :foreign_key => 'id', :order => 'version' - has_many :relation_members, :foreign_key => 'id' + has_many :relation_members, :foreign_key => 'id', :order => 'sequence_id' has_many :relation_tags, :foreign_key => 'id' has_many :containing_relation_members, :class_name => "RelationMember", :as => :member has_many :containing_relations, :class_name => "Relation", :through => :containing_relation_members, :source => :relation, :extend => ObjectFinder + validates_presence_of :id, :on => :update + validates_presence_of :timestamp,:version, :changeset_id + validates_uniqueness_of :id + validates_inclusion_of :visible, :in => [ true, false ] + validates_numericality_of :id, :on => :update, :integer_only => true + validates_numericality_of :changeset_id, :version, :integer_only => true + validates_associated :changeset + + TYPES = ["node", "way", "relation"] + def self.from_xml(xml, create=false) begin p = XML::Parser.string(xml) doc = p.parse - relation = Relation.new - doc.find('//osm/relation').each do |pt| - if !create and pt['id'] != '0' - relation.id = pt['id'].to_i - end + return Relation.from_xml_node(pt, create) + end + rescue LibXML::XML::Error, ArgumentError => ex + raise OSM::APIBadXMLError.new("relation", xml, ex.message) + end + end - if create - relation.timestamp = Time.now - relation.visible = true - else - if pt['timestamp'] - relation.timestamp = Time.parse(pt['timestamp']) - end - end + def self.from_xml_node(pt, create=false) + relation = Relation.new - pt.find('tag').each do |tag| - relation.add_tag_keyval(tag['k'], tag['v']) - end + if !create and pt['id'] != '0' + relation.id = pt['id'].to_i + end - pt.find('member').each do |member| - relation.add_member(member['type'], member['ref'], member['role']) - end + raise OSM::APIBadXMLError.new("relation", pt, "You are missing the required changeset in the relation") if pt['changeset'].nil? + relation.changeset_id = pt['changeset'] + + # The follow block does not need to be executed because they are dealt with + # in create_with_history, update_from and delete_with_history + if create + relation.timestamp = Time.now.getutc + relation.visible = true + relation.version = 0 + else + if pt['timestamp'] + relation.timestamp = Time.parse(pt['timestamp']) end - rescue - relation = nil + relation.version = pt['version'] + end + + pt.find('tag').each do |tag| + relation.add_tag_keyval(tag['k'], tag['v']) end + pt.find('member').each do |member| + #member_type = + logger.debug "each member" + raise OSM::APIBadXMLError.new("relation", pt, "The #{member['type']} is not allowed only, #{TYPES.inspect} allowed") unless TYPES.include? member['type'] + logger.debug "after raise" + #member_ref = member['ref'] + #member_role + member['role'] ||= "" # Allow the upload to not include this, in which case we default to an empty string. + logger.debug member['role'] + relation.add_member(member['type'].classify, member['ref'], member['role']) + end + raise OSM::APIBadUserInput.new("Some bad xml in relation") if relation.nil? + return relation end @@ -60,18 +92,23 @@ class Relation < ActiveRecord::Base el1['id'] = self.id.to_s el1['visible'] = self.visible.to_s el1['timestamp'] = self.timestamp.xmlschema + el1['version'] = self.version.to_s + el1['changeset'] = self.changeset_id.to_s user_display_name_cache = {} if user_display_name_cache.nil? - if user_display_name_cache and user_display_name_cache.key?(self.user_id) + if user_display_name_cache and user_display_name_cache.key?(self.changeset.user_id) # use the cache if available - elsif self.user.data_public? - user_display_name_cache[self.user_id] = self.user.display_name + elsif self.changeset.user.data_public? + user_display_name_cache[self.changeset.user_id] = self.changeset.user.display_name else - user_display_name_cache[self.user_id] = nil + user_display_name_cache[self.changeset.user_id] = nil end - el1['user'] = user_display_name_cache[self.user_id] unless user_display_name_cache[self.user_id].nil? + if not user_display_name_cache[self.changeset.user_id].nil? + el1['user'] = user_display_name_cache[self.changeset.user_id] + el1['uid'] = self.changeset.user_id.to_s + end self.relation_members.each do |member| p=0 @@ -88,7 +125,7 @@ class Relation < ActiveRecord::Base #end if p e = XML::Node.new 'member' - e['type'] = member.member_type + e['type'] = member.member_type.downcase e['ref'] = member.member_id.to_s e['role'] = member.member_role el1 << e @@ -108,7 +145,7 @@ class Relation < ActiveRecord::Base if ids.empty? return [] else - self.with_scope(:find => { :joins => "INNER JOIN current_relation_members ON current_relation_members.id = current_relations.id", :conditions => "current_relation_members.member_type = 'node' AND current_relation_members.member_id IN (#{ids.join(',')})" }) do + self.with_scope(:find => { :joins => "INNER JOIN current_relation_members ON current_relation_members.id = current_relations.id", :conditions => "current_relation_members.member_type = 'Node' AND current_relation_members.member_id IN (#{ids.join(',')})" }) do return self.find(:all, options) end end @@ -118,7 +155,7 @@ class Relation < ActiveRecord::Base if ids.empty? return [] else - self.with_scope(:find => { :joins => "INNER JOIN current_relation_members ON current_relation_members.id = current_relations.id", :conditions => "current_relation_members.member_type = 'way' AND current_relation_members.member_id IN (#{ids.join(',')})" }) do + self.with_scope(:find => { :joins => "INNER JOIN current_relation_members ON current_relation_members.id = current_relations.id", :conditions => "current_relation_members.member_type = 'Way' AND current_relation_members.member_id IN (#{ids.join(',')})" }) do return self.find(:all, options) end end @@ -128,7 +165,7 @@ class Relation < ActiveRecord::Base if ids.empty? return [] else - self.with_scope(:find => { :joins => "INNER JOIN current_relation_members ON current_relation_members.id = current_relations.id", :conditions => "current_relation_members.member_type = 'relation' AND current_relation_members.member_id IN (#{ids.join(',')})" }) do + self.with_scope(:find => { :joins => "INNER JOIN current_relation_members ON current_relation_members.id = current_relations.id", :conditions => "current_relation_members.member_type = 'Relation' AND current_relation_members.member_id IN (#{ids.join(',')})" }) do return self.find(:all, options) end end @@ -170,19 +207,164 @@ class Relation < ActiveRecord::Base def add_tag_keyval(k, v) @tags = Hash.new unless @tags + + # duplicate tags are now forbidden, so we can't allow values + # in the hash to be overwritten. + raise OSM::APIDuplicateTagsError.new("relation", self.id, k) if @tags.include? k + @tags[k] = v end + ## + # updates the changeset bounding box to contain the bounding box of + # the element with given +type+ and +id+. this only works with nodes + # and ways at the moment, as they're the only elements to respond to + # the :bbox call. + def update_changeset_element(type, id) + element = Kernel.const_get(type.capitalize).find(id) + changeset.update_bbox! element.bbox + end + + def delete_with_history!(new_relation, user) + unless self.visible + raise OSM::APIAlreadyDeletedError.new + end + + # need to start the transaction here, so that the database can + # provide repeatable reads for the used-by checks. this means it + # shouldn't be possible to get race conditions. + Relation.transaction do + check_consistency(self, new_relation, user) + # This will check to see if this relation is used by another relation + if RelationMember.find(:first, :joins => "INNER JOIN current_relations ON current_relations.id=current_relation_members.id", :conditions => [ "visible = ? AND member_type='Relation' and member_id=? ", true, self.id ]) + raise OSM::APIPreconditionFailedError.new("The relation #{new_relation.id} is a used in another relation") + end + self.changeset_id = new_relation.changeset_id + self.tags = {} + self.members = [] + self.visible = false + save_with_history! + end + end + + def update_from(new_relation, user) + check_consistency(self, new_relation, user) + if !new_relation.preconditions_ok? + raise OSM::APIPreconditionFailedError.new + end + self.changeset_id = new_relation.changeset_id + self.changeset = new_relation.changeset + self.tags = new_relation.tags + self.members = new_relation.members + self.visible = true + save_with_history! + end + + def create_with_history(user) + check_create_consistency(self, user) + if !self.preconditions_ok? + raise OSM::APIPreconditionFailedError.new + end + self.version = 0 + self.visible = true + save_with_history! + end + + def preconditions_ok? + # These are hastables that store an id in the index of all + # the nodes/way/relations that have already been added. + # If the member is valid and visible then we add it to the + # relevant hash table, with the value true as a cache. + # Thus if you have nodes with the ids of 50 and 1 already in the + # relation, then the hash table nodes would contain: + # => {50=>true, 1=>true} + elements = { :node => Hash.new, :way => Hash.new, :relation => Hash.new } + self.members.each do |m| + # find the hash for the element type or die + logger.debug m[0] + hash = elements[m[0].downcase.to_sym] or return false + # unless its in the cache already + unless hash.key? m[1] + # use reflection to look up the appropriate class + model = Kernel.const_get(m[0].capitalize) + # get the element with that ID + element = model.find(m[1]) + + # and check that it is OK to use. + unless element and element.visible? and element.preconditions_ok? + return false + end + hash[m[1]] = true + end + end + + return true + rescue + return false + end + + # Temporary method to match interface to nodes + def tags_as_hash + return self.tags + end + + ## + # if any members are referenced by placeholder IDs (i.e: negative) then + # this calling this method will fix them using the map from placeholders + # to IDs +id_map+. + def fix_placeholders!(id_map) + self.members.map! do |type, id, role| + old_id = id.to_i + if old_id < 0 + new_id = id_map[type.downcase.to_sym][old_id] + raise "invalid placeholder" if new_id.nil? + [type, new_id, role] + else + [type, id, role] + end + end + end + + private + def save_with_history! Relation.transaction do - t = Time.now + # have to be a little bit clever here - to detect if any tags + # changed then we have to monitor their before and after state. + tags_changed = false + + t = Time.now.getutc + self.version += 1 self.timestamp = t self.save! tags = self.tags + self.relation_tags.each do |old_tag| + key = old_tag.k + # if we can match the tags we currently have to the list + # of old tags, then we never set the tags_changed flag. but + # if any are different then set the flag and do the DB + # update. + if tags.has_key? key + # rails 2.1 dirty handling should take care of making this + # somewhat efficient... hopefully... + old_tag.v = tags[key] + tags_changed |= old_tag.changed? + old_tag.save! + + # remove from the map, so that we can expect an empty map + # at the end if there are no new tags + tags.delete key - RelationTag.delete_all(['id = ?', self.id]) - + else + # this means a tag was deleted + tags_changed = true + RelationTag.delete_all ['id = ? and k = ?', self.id, old_tag.k] + end + end + # if there are left-over tags then they are new and will have to + # be added. + tags_changed |= (not tags.empty?) tags.each do |k,v| tag = RelationTag.new tag.k = k @@ -190,81 +372,87 @@ class Relation < ActiveRecord::Base tag.id = self.id tag.save! end + + # reload, so that all of the members are accessible in their + # new state. + self.reload + + # same pattern as before, but this time we're collecting the + # changed members in an array, as the bounding box updates for + # elements are per-element, not blanked on/off like for tags. + changed_members = Array.new + members = Hash.new + self.members.each do |m| + # should be: h[[m.id, m.type]] = m.role, but someone prefers arrays + members[[m[1], m[0]]] = m[2] + end + relation_members.each do |old_member| + key = [old_member.member_id.to_s, old_member.member_type] + if members.has_key? key + members.delete key + else + changed_members << key + end + end + # any remaining members must be new additions + changed_members += members.keys + # update the members. first delete all the old members, as the new + # members may be in a different order and i don't feel like implementing + # a longest common subsequence algorithm to optimise this. members = self.members - - RelationMember.delete_all(['id = ?', self.id]) - - members.each do |n| + RelationMember.delete_all(:id => self.id) + members.each_with_index do |m,i| mem = RelationMember.new - mem.id = self.id - mem.member_type = n[0]; - mem.member_id = n[1]; - mem.member_role = n[2]; + mem.id = [self.id, i] + mem.member_type = m[0] + mem.member_id = m[1] + mem.member_role = m[2] mem.save! end old_relation = OldRelation.from_relation(self) old_relation.timestamp = t old_relation.save_with_dependencies! - end - end - def preconditions_ok? - # These are hastables that store an id in the index of all - # the nodes/way/relations that have already been added. - # Once we know the id of the node/way/relation exists - # we check to see if it is already existing in the hashtable - # if it does, then we return false. Otherwise - # we add it to the relevant hash table, with the value true.. - # Thus if you have nodes with the ids of 50 and 1 already in the - # relation, then the hash table nodes would contain: - # => {50=>true, 1=>true} - nodes = Hash.new - ways = Hash.new - relations = Hash.new - self.members.each do |m| - if (m[0] == "node") - n = Node.find(:first, :conditions => ["id = ?", m[1]]) - unless n and n.visible - return false - end - if nodes[m[1]] - return false - else - nodes[m[1]] = true - end - elsif (m[0] == "way") - w = Way.find(:first, :conditions => ["id = ?", m[1]]) - unless w and w.visible and w.preconditions_ok? - return false - end - if ways[m[1]] - return false - else - ways[m[1]] = true - end - elsif (m[0] == "relation") - e = Relation.find(:first, :conditions => ["id = ?", m[1]]) - unless e and e.visible and e.preconditions_ok? - return false - end - if relations[m[1]] - return false - else - relations[m[1]] = true + # update the bbox of the changeset and save it too. + # discussion on the mailing list gave the following definition for + # the bounding box update procedure of a relation: + # + # adding or removing nodes or ways from a relation causes them to be + # added to the changeset bounding box. adding a relation member or + # changing tag values causes all node and way members to be added to the + # bounding box. this is similar to how the map call does things and is + # reasonable on the assumption that adding or removing members doesn't + # materially change the rest of the relation. + any_relations = + changed_members.collect { |id,type| type == "relation" }. + inject(false) { |b,s| b or s } + + if tags_changed or any_relations + # add all non-relation bounding boxes to the changeset + # FIXME: check for tag changes along with element deletions and + # make sure that the deleted element's bounding box is hit. + self.members.each do |type, id, role| + if type != "Relation" + update_changeset_element(type, id) + end end else - return false + # add only changed members to the changeset + changed_members.each do |id, type| + if type != "Relation" + update_changeset_element(type, id) + end + end end + + # tell the changeset we updated one element only + changeset.add_changes! 1 + + # save the (maybe updated) changeset bounding box + changeset.save! end - return true - rescue - return false end - # Temporary method to match interface to nodes - def tags_as_hash - return self.tags - end end diff --git a/app/models/relation_member.rb b/app/models/relation_member.rb index 9ff4f46f3..b385dd6d1 100644 --- a/app/models/relation_member.rb +++ b/app/models/relation_member.rb @@ -1,19 +1,20 @@ class RelationMember < ActiveRecord::Base set_table_name 'current_relation_members' + set_primary_keys :id, :sequence_id belongs_to :member, :polymorphic => true, :foreign_type => :member_class belongs_to :relation, :foreign_key => :id def after_find - self[:member_class] = self.member_type.capitalize + self[:member_class] = self.member_type.classify end def after_initialize - self[:member_class] = self.member_type.capitalize + self[:member_class] = self.member_type.classify unless self.member_type.nil? end def before_save - self.member_type = self[:member_class].downcase + self.member_type = self[:member_class].classify end def member_type=(type) diff --git a/app/models/relation_tag.rb b/app/models/relation_tag.rb index 939165ebd..812b2ec35 100644 --- a/app/models/relation_tag.rb +++ b/app/models/relation_tag.rb @@ -3,4 +3,8 @@ class RelationTag < ActiveRecord::Base belongs_to :relation, :foreign_key => 'id' + validates_presence_of :id + validates_length_of :k, :v, :maximum => 255, :allow_blank => true + validates_uniqueness_of :id, :scope => :k + validates_numericality_of :id, :only_integer => true end diff --git a/app/models/trace.rb b/app/models/trace.rb index 10e867bad..03dbeb0b3 100644 --- a/app/models/trace.rb +++ b/app/models/trace.rb @@ -3,6 +3,8 @@ class Trace < ActiveRecord::Base validates_presence_of :user_id, :name, :timestamp validates_presence_of :description, :on => :create + validates_length_of :name, :maximum => 255 + validates_length_of :description, :maximum => 255 # validates_numericality_of :latitude, :longitude validates_inclusion_of :public, :inserted, :in => [ true, false] diff --git a/app/models/tracetag.rb b/app/models/tracetag.rb index f1d5967d5..f9833e141 100644 --- a/app/models/tracetag.rb +++ b/app/models/tracetag.rb @@ -2,6 +2,7 @@ class Tracetag < ActiveRecord::Base set_table_name 'gpx_file_tags' validates_format_of :tag, :with => /^[^\/;.,?]*$/ + validates_length_of :tag, :within => 1..255 belongs_to :trace, :foreign_key => 'gpx_id' end diff --git a/app/models/user.rb b/app/models/user.rb index fae037110..4113662aa 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,19 +4,21 @@ class User < ActiveRecord::Base has_many :traces has_many :diary_entries, :order => 'created_at DESC' has_many :messages, :foreign_key => :to_user_id, :order => 'sent_on DESC' - has_many :new_messages, :class_name => "Message", :foreign_key => :to_user_id, :conditions => "message_read = 0", :order => 'sent_on DESC' + has_many :new_messages, :class_name => "Message", :foreign_key => :to_user_id, :conditions => {:message_read => false}, :order => 'sent_on DESC' has_many :sent_messages, :class_name => "Message", :foreign_key => :from_user_id, :order => 'sent_on DESC' - has_many :friends, :include => :befriendee, :conditions => "users.visible = 1" + has_many :friends, :include => :befriendee, :conditions => ["users.visible = ?", true] has_many :tokens, :class_name => "UserToken" has_many :preferences, :class_name => "UserPreference" + has_many :changesets validates_presence_of :email, :display_name validates_confirmation_of :email, :message => 'Email addresses must match' validates_confirmation_of :pass_crypt, :message => 'Password must match the confirmation password' validates_uniqueness_of :display_name, :allow_nil => true validates_uniqueness_of :email - validates_length_of :pass_crypt, :minimum => 8 - validates_length_of :display_name, :minimum => 3, :allow_nil => true + validates_length_of :pass_crypt, :within => 8..255 + validates_length_of :display_name, :within => 3..255, :allow_nil => true + validates_length_of :email, :within => 6..255 validates_format_of :email, :with => /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i validates_format_of :display_name, :with => /^[^\/;.,?]*$/ validates_numericality_of :home_lat, :allow_nil => true @@ -28,7 +30,7 @@ class User < ActiveRecord::Base file_column :image, :magick => { :geometry => "100x100>" } def after_initialize - self.creation_time = Time.now if self.creation_time.nil? + self.creation_time = Time.now.getutc if self.creation_time.nil? end def encrypt_password @@ -80,7 +82,7 @@ class User < ActiveRecord::Base if self.home_lon and self.home_lat gc = OSM::GreatCircle.new(self.home_lat, self.home_lon) bounds = gc.bounds(radius) - nearby = User.find(:all, :conditions => "visible = 1 and home_lat between #{bounds[:minlat]} and #{bounds[:maxlat]} and home_lon between #{bounds[:minlon]} and #{bounds[:maxlon]} and data_public = 1 and id != #{self.id}") + nearby = User.find(:all, :conditions => ["visible = ? and home_lat between #{bounds[:minlat]} and #{bounds[:maxlat]} and home_lon between #{bounds[:minlon]} and #{bounds[:maxlon]} and data_public = ? and id != #{self.id}", true, true]) nearby.delete_if { |u| gc.distance(u.home_lat, u.home_lon) > radius } nearby.sort! { |u1,u2| gc.distance(u1.home_lat, u1.home_lon) <=> gc.distance(u2.home_lat, u2.home_lon) } else @@ -104,6 +106,10 @@ class User < ActiveRecord::Base return false end + def trace_public_default + return self.preferences.find(:first, :conditions => {:k => "gps.trace.public", :v => "default"}) + end + def delete self.active = false self.display_name = "user_#{self.id}" diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 3985a527e..28ef40f1d 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -1,6 +1,9 @@ class UserPreference < ActiveRecord::Base set_primary_keys :user_id, :k belongs_to :user + + validates_length_of :k, :within => 1..255 + validates_length_of :v, :within => 1..255 # Turn this Node in to an XML Node without the wrapper. def to_xml_node diff --git a/app/models/way.rb b/app/models/way.rb index 6c3ea9e46..e134d697d 100644 --- a/app/models/way.rb +++ b/app/models/way.rb @@ -1,9 +1,11 @@ class Way < ActiveRecord::Base require 'xml/libxml' + + include ConsistencyValidations set_table_name 'current_ways' - - belongs_to :user + + belongs_to :changeset has_many :old_ways, :foreign_key => 'id', :order => 'version' @@ -15,37 +17,56 @@ class Way < ActiveRecord::Base has_many :containing_relation_members, :class_name => "RelationMember", :as => :member has_many :containing_relations, :class_name => "Relation", :through => :containing_relation_members, :source => :relation, :extend => ObjectFinder + validates_presence_of :id, :on => :update + validates_presence_of :changeset_id,:version, :timestamp + validates_uniqueness_of :id + validates_inclusion_of :visible, :in => [ true, false ] + validates_numericality_of :changeset_id, :version, :integer_only => true + validates_numericality_of :id, :on => :update, :integer_only => true + validates_associated :changeset + def self.from_xml(xml, create=false) begin p = XML::Parser.string(xml) doc = p.parse - way = Way.new - doc.find('//osm/way').each do |pt| - if !create and pt['id'] != '0' - way.id = pt['id'].to_i - end - - if create - way.timestamp = Time.now - way.visible = true - else - if pt['timestamp'] - way.timestamp = Time.parse(pt['timestamp']) - end - end + return Way.from_xml_node(pt, create) + end + rescue LibXML::XML::Error, ArgumentError => ex + raise OSM::APIBadXMLError.new("way", xml, ex.message) + end + end - pt.find('tag').each do |tag| - way.add_tag_keyval(tag['k'], tag['v']) - end + def self.from_xml_node(pt, create=false) + way = Way.new - pt.find('nd').each do |nd| - way.add_nd_num(nd['ref']) - end + if !create and pt['id'] != '0' + way.id = pt['id'].to_i + end + + way.version = pt['version'] + raise OSM::APIBadXMLError.new("node", pt, "Changeset is required") if pt['changeset'].nil? + way.changeset_id = pt['changeset'] + + # This next section isn't required for the create, update, or delete of ways + if create + way.timestamp = Time.now.getutc + way.visible = true + else + if pt['timestamp'] + way.timestamp = Time.parse(pt['timestamp']) end - rescue - way = nil + # if visible isn't present then it defaults to true + way.visible = (pt['visible'] or true) + end + + pt.find('tag').each do |tag| + way.add_tag_keyval(tag['k'], tag['v']) + end + + pt.find('nd').each do |nd| + way.add_nd_num(nd['ref']) end return way @@ -73,18 +94,23 @@ class Way < ActiveRecord::Base el1['id'] = self.id.to_s el1['visible'] = self.visible.to_s el1['timestamp'] = self.timestamp.xmlschema + el1['version'] = self.version.to_s + el1['changeset'] = self.changeset_id.to_s user_display_name_cache = {} if user_display_name_cache.nil? - if user_display_name_cache and user_display_name_cache.key?(self.user_id) + if user_display_name_cache and user_display_name_cache.key?(self.changeset.user_id) # use the cache if available - elsif self.user.data_public? - user_display_name_cache[self.user_id] = self.user.display_name + elsif self.changeset.user.data_public? + user_display_name_cache[self.changeset.user_id] = self.changeset.user.display_name else - user_display_name_cache[self.user_id] = nil + user_display_name_cache[self.changeset.user_id] = nil end - el1['user'] = user_display_name_cache[self.user_id] unless user_display_name_cache[self.user_id].nil? + if not user_display_name_cache[self.changeset.user_id].nil? + el1['user'] = user_display_name_cache[self.changeset.user_id] + el1['uid'] = self.changeset.user_id.to_s + end # make sure nodes are output in sequence_id order ordered_nodes = [] @@ -96,7 +122,7 @@ class Way < ActiveRecord::Base end else # otherwise, manually go to the db to check things - if nd.node.visible? and nd.node.visible? + if nd.node and nd.node.visible? ordered_nodes[nd.sequence_id] = nd.node_id.to_s end end @@ -154,99 +180,86 @@ class Way < ActiveRecord::Base def add_tag_keyval(k, v) @tags = Hash.new unless @tags - @tags[k] = v - end - def save_with_history! - t = Time.now + # duplicate tags are now forbidden, so we can't allow values + # in the hash to be overwritten. + raise OSM::APIDuplicateTagsError.new("way", self.id, k) if @tags.include? k - Way.transaction do - self.timestamp = t - self.save! - end - - WayTag.transaction do - tags = self.tags + @tags[k] = v + end - WayTag.delete_all(['id = ?', self.id]) + ## + # the integer coords (i.e: unscaled) bounding box of the way, assuming + # straight line segments. + def bbox + lons = nodes.collect { |n| n.longitude } + lats = nodes.collect { |n| n.latitude } + [ lons.min, lats.min, lons.max, lats.max ] + end - tags.each do |k,v| - tag = WayTag.new - tag.k = k - tag.v = v - tag.id = self.id - tag.save! - end + def update_from(new_way, user) + check_consistency(self, new_way, user) + if !new_way.preconditions_ok? + raise OSM::APIPreconditionFailedError.new end - WayNode.transaction do - nds = self.nds - - WayNode.delete_all(['id = ?', self.id]) + self.changeset_id = new_way.changeset_id + self.changeset = new_way.changeset + self.tags = new_way.tags + self.nds = new_way.nds + self.visible = true + save_with_history! + end - sequence = 1 - nds.each do |n| - nd = WayNode.new - nd.id = [self.id, sequence] - nd.node_id = n - nd.save! - sequence += 1 - end + def create_with_history(user) + check_create_consistency(self, user) + if !self.preconditions_ok? + raise OSM::APIPreconditionFailedError.new end - - old_way = OldWay.from_way(self) - old_way.timestamp = t - old_way.save_with_dependencies! + self.version = 0 + self.visible = true + save_with_history! end def preconditions_ok? return false if self.nds.empty? + if self.nds.length > APP_CONFIG['max_number_of_way_nodes'] + raise OSM::APITooManyWayNodesError.new(self.nds.count, APP_CONFIG['max_number_of_way_nodes']) + end self.nds.each do |n| node = Node.find(:first, :conditions => ["id = ?", n]) unless node and node.visible - return false + raise OSM::APIPreconditionFailedError.new("The node with id #{n} either does not exist, or is not visible") end end return true end - # Delete the way and it's relations, but don't really delete it - set its visibility to false and update the history etc to maintain wiki-like functionality. - def delete_with_relations_and_history(user) - if self.visible - # FIXME - # this should actually delete the relations, - # not just throw a PreconditionFailed if it's a member of a relation!! + def delete_with_history!(new_way, user) + unless self.visible + raise OSM::APIAlreadyDeletedError + end + + # need to start the transaction here, so that the database can + # provide repeatable reads for the used-by checks. this means it + # shouldn't be possible to get race conditions. + Way.transaction do + check_consistency(self, new_way, user) if RelationMember.find(:first, :joins => "INNER JOIN current_relations ON current_relations.id=current_relation_members.id", - :conditions => [ "visible = 1 AND member_type='way' and member_id=?", self.id]) - raise OSM::APIPreconditionFailedError - # end FIXME + :conditions => [ "visible = ? AND member_type='Way' and member_id=? ", true, self.id]) + raise OSM::APIPreconditionFailedError.new("You need to make sure that this way is not a member of a relation.") else - self.user_id = user.id + self.changeset_id = new_way.changeset_id + self.changeset = new_way.changeset + self.tags = [] self.nds = [] self.visible = false - self.save_with_history! + save_with_history! end - else - raise OSM::APIAlreadyDeletedError end end - # delete a way and it's nodes that aren't part of other ways, with history - def delete_with_relations_and_nodes_and_history(user) - # delete the nodes not used by other ways - self.unshared_node_ids.each do |node_id| - n = Node.find(node_id) - n.user_id = user.id - n.visible = false - n.save_with_history! - end - - self.user_id = user.id - - self.delete_with_relations_and_history(user) - end - # Find nodes that belong to this way only def unshared_node_ids node_ids = self.nodes.collect { |node| node.id } @@ -263,4 +276,78 @@ class Way < ActiveRecord::Base def tags_as_hash return self.tags end + + ## + # if any referenced nodes are placeholder IDs (i.e: are negative) then + # this calling this method will fix them using the map from placeholders + # to IDs +id_map+. + def fix_placeholders!(id_map) + self.nds.map! do |node_id| + if node_id < 0 + new_id = id_map[:node][node_id] + raise "invalid placeholder for #{node_id.inspect}: #{new_id.inspect}" if new_id.nil? + new_id + else + node_id + end + end + end + + private + + def save_with_history! + t = Time.now.getutc + + # update the bounding box, note that this has to be done both before + # and after the save, so that nodes from both versions are included in the + # bbox. we use a copy of the changeset so that it isn't reloaded + # later in the save. + cs = self.changeset + cs.update_bbox!(bbox) unless nodes.empty? + + Way.transaction do + self.version += 1 + self.timestamp = t + self.save! + + tags = self.tags + WayTag.delete_all(['id = ?', self.id]) + tags.each do |k,v| + tag = WayTag.new + tag.k = k + tag.v = v + tag.id = self.id + tag.save! + end + + nds = self.nds + WayNode.delete_all(['id = ?', self.id]) + sequence = 1 + nds.each do |n| + nd = WayNode.new + nd.id = [self.id, sequence] + nd.node_id = n + nd.save! + sequence += 1 + end + + old_way = OldWay.from_way(self) + old_way.timestamp = t + old_way.save_with_dependencies! + + # reload the way so that the nodes array points to the correct + # new set of nodes. + self.reload + + # update and commit the bounding box, now that way nodes + # have been updated and we're in a transaction. + cs.update_bbox!(bbox) unless nodes.empty? + + # tell the changeset we updated one element only + cs.add_changes! 1 + + cs.save! + end + end + end diff --git a/app/models/way_tag.rb b/app/models/way_tag.rb index 4548674d4..fa9b43361 100644 --- a/app/models/way_tag.rb +++ b/app/models/way_tag.rb @@ -6,4 +6,9 @@ class WayTag < ActiveRecord::Base # FIXME add a real multipart key to waytags so that we can do eager loadin belongs_to :way, :foreign_key => 'id' + + validates_presence_of :id + validates_length_of :k, :v, :maximum => 255, :allow_blank => true + validates_uniqueness_of :id, :scope => :k + validates_numericality_of :id, :only_integer => true end diff --git a/app/views/browse/_changeset_details.rhtml b/app/views/browse/_changeset_details.rhtml new file mode 100644 index 000000000..12d4dfb25 --- /dev/null +++ b/app/views/browse/_changeset_details.rhtml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + <% if changeset_details.user.data_public? %> + + + + + <% end %> + + <%= render :partial => "tag_details", :object => changeset_details %> + + + + <% unless changeset_details.has_valid_bbox? %> + + <% else + minlon = changeset_details.min_lon/GeoRecord::SCALE.to_f + minlat = changeset_details.min_lat/GeoRecord::SCALE.to_f + maxlon = changeset_details.max_lon/GeoRecord::SCALE.to_f + maxlat = changeset_details.max_lat/GeoRecord::SCALE.to_f + %> + + <% end %> + + + <% unless @nodes.empty? %> + + + + + <%= render :partial => 'paging_nav', :locals => { :pages => @node_pages, :page_param => "node_page"} %> + <% end %> + + <% unless @ways.empty? %> + + + + + <%= render :partial => 'paging_nav', :locals => { :pages => @way_pages, :page_param => "way_page" } %> + <% end %> + + <% unless @relations.empty? %> + + + + + <%= render :partial => 'paging_nav', :locals => { :pages => @relation_pages, :page_param => "relation_page" } %> + <% end %> + +
Created at:<%= h(changeset_details.created_at) %>
Closed at:<%= h(changeset_details.closed_at) %>
Belongs to:<%= link_to h(changeset_details.user.display_name), :controller => "user", :action => "view", :display_name => changeset_details.user.display_name %>
Bounding box:No bounding box has been stored for this changeset. + + + + + + + + + + + + +
<%=maxlat -%>
<%=minlon -%>(box)<%=maxlon -%>
<%= minlon -%>
+
Has the following <%= @node_pages.item_count %> nodes: + + <% @nodes.each do |node| %> + + <% end %> +
<%= link_to "Node #{node.id.to_s}, version #{node.version.to_s}", :action => "node", :id => node.id.to_s %>
+
Has the following <%= @way_pages.item_count %> ways: + + <% @ways.each do |way| %> + + <% end %> + <%= + #render :partial => "containing_relation", :collection => changeset_details.containing_relation_members + %> +
<%= link_to "Way #{way.id.to_s}, version #{way.version.to_s}", :action => "way", :id => way.id.to_s %>
+
Has the following <%= @relation_pages.item_count %> relations: + + <% @relations.each do |relation| %> + + <% end %> +
<%= link_to "Relation #{relation.id.to_s}, version #{relation.version.to_s}", :action => "relation", :id => relation.id.to_s %>
+
diff --git a/app/views/browse/_common_details.rhtml b/app/views/browse/_common_details.rhtml index ee5f22cee..09cf4cf2d 100644 --- a/app/views/browse/_common_details.rhtml +++ b/app/views/browse/_common_details.rhtml @@ -3,20 +3,21 @@ <%= h(common_details.timestamp) %> -<% if common_details.user.data_public %> +<% if common_details.changeset.user.data_public? %> Edited by: - <%= link_to h(common_details.user.display_name), :controller => "user", :action => "view", :display_name => common_details.user.display_name %> + <%= link_to h(common_details.changeset.user.display_name), :controller => "user", :action => "view", :display_name => common_details.changeset.user.display_name %> <% end %> -<% unless common_details.tags_as_hash.empty? %> - - Tags: - - - <%= render :partial => "tag", :collection => common_details.tags_as_hash %> -
- - -<% end %> + + Version: + <%= h(common_details.version) %> + + + + In changeset: + <%= link_to common_details.changeset_id, :action => :changeset, :id => common_details.changeset_id %> + + +<%= render :partial => "tag_details", :object => common_details %> diff --git a/app/views/browse/_map.rhtml b/app/views/browse/_map.rhtml index ad2d2d307..d972104ba 100644 --- a/app/views/browse/_map.rhtml +++ b/app/views/browse/_map.rhtml @@ -2,7 +2,7 @@ <%= javascript_include_tag '/openlayers/OpenStreetMap.js' %> <%= javascript_include_tag 'map.js' %> - <% if map.visible %> + <% if map.instance_of? Changeset or map.visible %>
Loading... @@ -13,6 +13,25 @@ + + + + +
+ +

Composite Primary Keys

+
+ Get Version + 1.1.0 +
+

&#x2192; Ruby on Rails

+

&#x2192; ActiveRecords

+

What

+

Ruby on Rails does not support composite primary keys. This free software is an extension
+to the database layer of Rails – ActiveRecords – to support composite primary keys as transparently as possible.

+

Any Ruby script using ActiveRecords can use Composite Primary Keys with this library.

+

Installing

+

sudo gem install composite_primary_keys

+

Rails: Add the following to the bottom of your environment.rb file

+

require 'composite_primary_keys'

+

Ruby scripts: Add the following to the top of your script

+

require 'rubygems'
+require 'composite_primary_keys'

+

The basics

+

A model with composite primary keys would look like…

+

class Membership < ActiveRecord::Base
+  # set_primary_keys *keys - turns on composite key functionality
+  set_primary_keys :user_id, :group_id
+  belongs_to :user
+  belongs_to :group
+  has_many :statuses, :class_name => 'MembershipStatus', :foreign_key => [:user_id, :group_id]
+end

+

A model associated with a composite key model would be defined like…

+

class MembershipStatus < ActiveRecord::Base
+  belongs_to :membership, :foreign_key => [:user_id, :group_id]
+end

+

That is, associations can include composite keys too. Nice.

+

Demonstration of usage

+

Once you’ve created your models to specify composite primary keys (such as the Membership class) and associations (such as MembershipStatus#membership), you can uses them like any normal model with associations.

+

But first, lets check out our primary keys.

+

MembershipStatus.primary_key # => "id"    # normal single key
+Membership.primary_key  # => [:user_id, :group_id] # composite keys
+Membership.primary_key.to_s # => "user_id,group_id"

+

Now we want to be able to find instances using the same syntax we always use for ActiveRecords…

+

MembershipStatus.find(1)    # single id returns single instance
+=> <MembershipStatus:0x392a8c8 @attributes={"id"=>"1", "status"=>"Active"}>
+Membership.find(1,1)  # composite ids returns single instance
+=> <Membership:0x39218b0 @attributes={"user_id"=>"1", "group_id"=>"1"}>

+

Using Ruby on Rails? You’ll want to your url_for helpers
+to convert composite keys into strings and back again…

+

Membership.find(:first).to_param # => "1,1"

+

And then use the string id within your controller to find the object again

+

params[:id] # => '1,1'
+Membership.find(params[:id])
+=> <Membership:0x3904288 @attributes={"user_id"=>"1", "group_id"=>"1"}>

+

That is, an ActiveRecord supporting composite keys behaves transparently
+throughout your application. Just like a normal ActiveRecord.

+

Other tricks

+

Pass a list of composite ids to the #find method

+

Membership.find [1,1], [2,1]
+=> [
+  <Membership:0x394ade8 @attributes={"user_id"=>"1", "group_id"=>"1"}>, 
+  <Membership:0x394ada0 @attributes={"user_id"=>"2", "group_id"=>"1"}>
+]

+

Perform #count operations

+

MembershipStatus.find(:first).memberships.count # => 1

+

Routes with Rails

+

From Pete Sumskas:

+
+

I ran into one problem that I didn’t see mentioned on this list
+ and I didn’t see any information about what I should do to address it in the
+ documentation (might have missed it).

+

The problem was that the urls being generated for a ‘show’ action (for
+ example) had a syntax like:
+
+

/controller/show/123000,Bu70

+

for a two-field composite PK. The default routing would not match that,
+ so after working out how to do the routing I added:
+
+

map.connect ':controller/:action/:id', :id => /\w+(,\w+)*/

+
+ to my route.rb file.

+
+

+

Which databases?

+

A suite of unit tests have been run on the following databases supported by ActiveRecord:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DatabaseTest SuccessUser feedback
mysql YESYES (Yes! or No…)
sqlite3 YESYES (Yes! or No…)
postgresqlYESYES (Yes! or No…)
oracle YESYES (Yes! or No…)
sqlserver ??? (I can help)??? (Yes! or No…)
db2 ??? (I can help)??? (Yes! or No…)
firebird ??? (I can help)??? (Yes! or No…)
sybase ??? (I can help)??? (Yes! or No…)
openbase ??? (I can help)??? (Yes! or No…)
frontbase ??? (I can help)??? (Yes! or No…)
+

Dr Nic’s Blog

+

http://www.drnicwilliams.com – for future announcements and
+other stories and things.

+

Forum

+

http://groups.google.com/group/compositekeys

+

How to submit patches

+

Read the 8 steps for fixing other people’s code and for section 8b: Submit patch to Google Groups, use the Google Group above.

+

The source for this project is available via git. You can browse and/or fork the source, or to clone the project locally:
+
+

git clone git://github.com/drnic/composite_primary_keys.git

+

Licence

+

This code is free to use under the terms of the MIT licence.

+

Contact

+

Comments are welcome. Send an email to Dr Nic Williams.

+

+ Dr Nic, 25th October 2008
+ Theme extended from Paul Battley +

+
+ + + + + + diff --git a/vendor/gems/composite_primary_keys-1.1.0/website/index.txt b/vendor/gems/composite_primary_keys-1.1.0/website/index.txt new file mode 100644 index 000000000..fd66d978e --- /dev/null +++ b/vendor/gems/composite_primary_keys-1.1.0/website/index.txt @@ -0,0 +1,159 @@ +h1. Composite Primary Keys + +h1. → Ruby on Rails + +h1. → ActiveRecords + +h2. What + +Ruby on Rails does not support composite primary keys. This free software is an extension +to the database layer of Rails - "ActiveRecords":http://wiki.rubyonrails.com/rails/pages/ActiveRecord - to support composite primary keys as transparently as possible. + +Any Ruby script using ActiveRecords can use Composite Primary Keys with this library. + +h2. Installing + +
sudo gem install composite_primary_keys
+ +Rails: Add the following to the bottom of your environment.rb file + +
require 'composite_primary_keys'
+ +Ruby scripts: Add the following to the top of your script + +
require 'rubygems'
+require 'composite_primary_keys'
+ +h2. The basics + +A model with composite primary keys would look like... + +
class Membership < ActiveRecord::Base
+  # set_primary_keys *keys - turns on composite key functionality
+  set_primary_keys :user_id, :group_id
+  belongs_to :user
+  belongs_to :group
+  has_many :statuses, :class_name => 'MembershipStatus', :foreign_key => [:user_id, :group_id]
+end
+ +A model associated with a composite key model would be defined like... + +
class MembershipStatus < ActiveRecord::Base
+  belongs_to :membership, :foreign_key => [:user_id, :group_id]
+end
+ +That is, associations can include composite keys too. Nice. + +h2. Demonstration of usage + +Once you've created your models to specify composite primary keys (such as the Membership class) and associations (such as MembershipStatus#membership), you can uses them like any normal model with associations. + +But first, lets check out our primary keys. + +
MembershipStatus.primary_key # => "id"    # normal single key
+Membership.primary_key  # => [:user_id, :group_id] # composite keys
+Membership.primary_key.to_s # => "user_id,group_id"
+ +Now we want to be able to find instances using the same syntax we always use for ActiveRecords... + +
MembershipStatus.find(1)    # single id returns single instance
+=> "1", "status"=>"Active"}>
+Membership.find(1,1)  # composite ids returns single instance
+=> "1", "group_id"=>"1"}>
+ +Using "Ruby on Rails":http://www.rubyonrails.org? You'll want to your url_for helpers +to convert composite keys into strings and back again... + +
Membership.find(:first).to_param # => "1,1"
+ +And then use the string id within your controller to find the object again + +
params[:id] # => '1,1'
+Membership.find(params[:id])
+=> "1", "group_id"=>"1"}>
+ +That is, an ActiveRecord supporting composite keys behaves transparently +throughout your application. Just like a normal ActiveRecord. + + +h2. Other tricks + +h3. Pass a list of composite ids to the #find method + +
Membership.find [1,1], [2,1]
+=> [
+  "1", "group_id"=>"1"}>, 
+  "2", "group_id"=>"1"}>
+]
+ +Perform #count operations + +
MembershipStatus.find(:first).memberships.count # => 1
+ +h3. Routes with Rails + +From Pete Sumskas: + +
+ I ran into one problem that I didn't see mentioned on "this list":http://groups.google.com/group/compositekeys - + and I didn't see any information about what I should do to address it in the + documentation (might have missed it). + + The problem was that the urls being generated for a 'show' action (for + example) had a syntax like: + +
/controller/show/123000,Bu70
+ + for a two-field composite PK. The default routing would not match that, + so after working out how to do the routing I added: + +
map.connect ':controller/:action/:id', :id => /\w+(,\w+)*/
+ + to my route.rb file. + +
+ + + +h2. Which databases? + + +A suite of unit tests have been run on the following databases supported by ActiveRecord: + +|_.Database|_.Test Success|_.User feedback| +|mysql |YES|YES ("Yes!":mailto:compositekeys@googlegroups.com?subject=Mysql+is+working or "No...":mailto:compositekeys@googlegroups.com?subject=Mysql+is+failing)| +|sqlite3 |YES|YES ("Yes!":mailto:compositekeys@googlegroups.com?subject=Sqlite3+is+working or "No...":mailto:compositekeys@googlegroups.com?subject=Sqlite3+is+failing)| +|postgresql|YES|YES ("Yes!":mailto:compositekeys@googlegroups.com?subject=Postgresql+is+working or "No...":mailto:compositekeys@googlegroups.com?subject=Postgresql+is+failing)| +|oracle |YES|YES ("Yes!":mailto:compositekeys@googlegroups.com?subject=Oracle+is+working or "No...":mailto:compositekeys@googlegroups.com?subject=Oracle+is+failing)| +|sqlserver |??? ("I can help":mailto:compositekeys@googlegroups.com?subject=Help+with+SQLServer)|??? ("Yes!":mailto:compositekeys@googlegroups.com?subject=SQLServer+is+working or "No...":mailto:compositekeys@googlegroups.com?subject=SQLServer+is+failing)| +|db2 |??? ("I can help":mailto:compositekeys@googlegroups.com?subject=Help+with+DB2)|??? ("Yes!":mailto:compositekeys@googlegroups.com?subject=DB2+is+working or "No...":mailto:compositekeys@googlegroups.com?subject=DB2+is+failing)| +|firebird |??? ("I can help":mailto:compositekeys@googlegroups.com?subject=Help+with+Firebird)|??? ("Yes!":mailto:compositekeys@googlegroups.com?subject=Firebird+is+working or "No...":mailto:compositekeys@googlegroups.com?subject=Firebird+is+failing)| +|sybase |??? ("I can help":mailto:compositekeys@googlegroups.com?subject=Help+with+Sybase)|??? ("Yes!":mailto:compositekeys@googlegroups.com?subject=Sybase+is+working or "No...":mailto:compositekeys@googlegroups.com?subject=Sybase+is+failing)| +|openbase |??? ("I can help":mailto:compositekeys@googlegroups.com?subject=Help+with+Openbase)|??? ("Yes!":mailto:compositekeys@googlegroups.com?subject=Openbase+is+working or "No...":mailto:compositekeys@googlegroups.com?subject=Openbase+is+failing)| +|frontbase |??? ("I can help":mailto:compositekeys@googlegroups.com?subject=Help+with+Frontbase)|??? ("Yes!":mailto:compositekeys@googlegroups.com?subject=Frontbase+is+working or "No...":mailto:compositekeys@googlegroups.com?subject=Frontbase+is+failing)| + +h2. Dr Nic's Blog + +"http://www.drnicwilliams.com":http://www.drnicwilliams.com - for future announcements and +other stories and things. + +h2. Forum + +"http://groups.google.com/group/compositekeys":http://groups.google.com/group/compositekeys + +h2. How to submit patches + +Read the "8 steps for fixing other people's code":http://drnicwilliams.com/2007/06/01/8-steps-for-fixing-other-peoples-code/ and for section "8b: Submit patch to Google Groups":http://drnicwilliams.com/2007/06/01/8-steps-for-fixing-other-peoples-code/#8b-google-groups, use the Google Group above. + + +The source for this project is available via git. You can "browse and/or fork the source":http://github.com/drnic/composite_primary_keys/tree/master, or to clone the project locally: + +
git clone git://github.com/drnic/composite_primary_keys.git
+ +h2. Licence + +This code is free to use under the terms of the MIT licence. + +h2. Contact + +Comments are welcome. Send an email to "Dr Nic Williams":mailto:drnicwilliams@gmail.com. diff --git a/vendor/gems/composite_primary_keys-1.1.0/website/javascripts/rounded_corners_lite.inc.js b/vendor/gems/composite_primary_keys-1.1.0/website/javascripts/rounded_corners_lite.inc.js new file mode 100644 index 000000000..afc3ea327 --- /dev/null +++ b/vendor/gems/composite_primary_keys-1.1.0/website/javascripts/rounded_corners_lite.inc.js @@ -0,0 +1,285 @@ + + /**************************************************************** + * * + * curvyCorners * + * ------------ * + * * + * This script generates rounded corners for your divs. * + * * + * Version 1.2.9 * + * Copyright (c) 2006 Cameron Cooke * + * By: Cameron Cooke and Tim Hutchison. * + * * + * * + * Website: http://www.curvycorners.net * + * Email: info@totalinfinity.com * + * Forum: http://www.curvycorners.net/forum/ * + * * + * * + * This library is free software; you can redistribute * + * it and/or modify it under the terms of the GNU * + * Lesser General Public License as published by the * + * Free Software Foundation; either version 2.1 of the * + * License, or (at your option) any later version. * + * * + * This library is distributed in the hope that it will * + * be useful, but WITHOUT ANY WARRANTY; without even the * + * implied warranty of MERCHANTABILITY or FITNESS FOR A * + * PARTICULAR PURPOSE. See the GNU Lesser General Public * + * License for more details. * + * * + * You should have received a copy of the GNU Lesser * + * General Public License along with this library; * + * Inc., 59 Temple Place, Suite 330, Boston, * + * MA 02111-1307 USA * + * * + ****************************************************************/ + +var isIE = navigator.userAgent.toLowerCase().indexOf("msie") > -1; var isMoz = document.implementation && document.implementation.createDocument; var isSafari = ((navigator.userAgent.toLowerCase().indexOf('safari')!=-1)&&(navigator.userAgent.toLowerCase().indexOf('mac')!=-1))?true:false; function curvyCorners() +{ if(typeof(arguments[0]) != "object") throw newCurvyError("First parameter of curvyCorners() must be an object."); if(typeof(arguments[1]) != "object" && typeof(arguments[1]) != "string") throw newCurvyError("Second parameter of curvyCorners() must be an object or a class name."); if(typeof(arguments[1]) == "string") +{ var startIndex = 0; var boxCol = getElementsByClass(arguments[1]);} +else +{ var startIndex = 1; var boxCol = arguments;} +var curvyCornersCol = new Array(); if(arguments[0].validTags) +var validElements = arguments[0].validTags; else +var validElements = ["div"]; for(var i = startIndex, j = boxCol.length; i < j; i++) +{ var currentTag = boxCol[i].tagName.toLowerCase(); if(inArray(validElements, currentTag) !== false) +{ curvyCornersCol[curvyCornersCol.length] = new curvyObject(arguments[0], boxCol[i]);} +} +this.objects = curvyCornersCol; this.applyCornersToAll = function() +{ for(var x = 0, k = this.objects.length; x < k; x++) +{ this.objects[x].applyCorners();} +} +} +function curvyObject() +{ this.box = arguments[1]; this.settings = arguments[0]; this.topContainer = null; this.bottomContainer = null; this.masterCorners = new Array(); this.contentDIV = null; var boxHeight = get_style(this.box, "height", "height"); var boxWidth = get_style(this.box, "width", "width"); var borderWidth = get_style(this.box, "borderTopWidth", "border-top-width"); var borderColour = get_style(this.box, "borderTopColor", "border-top-color"); var boxColour = get_style(this.box, "backgroundColor", "background-color"); var backgroundImage = get_style(this.box, "backgroundImage", "background-image"); var boxPosition = get_style(this.box, "position", "position"); var boxPadding = get_style(this.box, "paddingTop", "padding-top"); this.boxHeight = parseInt(((boxHeight != "" && boxHeight != "auto" && boxHeight.indexOf("%") == -1)? boxHeight.substring(0, boxHeight.indexOf("px")) : this.box.scrollHeight)); this.boxWidth = parseInt(((boxWidth != "" && boxWidth != "auto" && boxWidth.indexOf("%") == -1)? boxWidth.substring(0, boxWidth.indexOf("px")) : this.box.scrollWidth)); this.borderWidth = parseInt(((borderWidth != "" && borderWidth.indexOf("px") !== -1)? borderWidth.slice(0, borderWidth.indexOf("px")) : 0)); this.boxColour = format_colour(boxColour); this.boxPadding = parseInt(((boxPadding != "" && boxPadding.indexOf("px") !== -1)? boxPadding.slice(0, boxPadding.indexOf("px")) : 0)); this.borderColour = format_colour(borderColour); this.borderString = this.borderWidth + "px" + " solid " + this.borderColour; this.backgroundImage = ((backgroundImage != "none")? backgroundImage : ""); this.boxContent = this.box.innerHTML; if(boxPosition != "absolute") this.box.style.position = "relative"; this.box.style.padding = "0px"; if(isIE && boxWidth == "auto" && boxHeight == "auto") this.box.style.width = "100%"; if(this.settings.autoPad == true && this.boxPadding > 0) +this.box.innerHTML = ""; this.applyCorners = function() +{ for(var t = 0; t < 2; t++) +{ switch(t) +{ case 0: +if(this.settings.tl || this.settings.tr) +{ var newMainContainer = document.createElement("DIV"); newMainContainer.style.width = "100%"; newMainContainer.style.fontSize = "1px"; newMainContainer.style.overflow = "hidden"; newMainContainer.style.position = "absolute"; newMainContainer.style.paddingLeft = this.borderWidth + "px"; newMainContainer.style.paddingRight = this.borderWidth + "px"; var topMaxRadius = Math.max(this.settings.tl ? this.settings.tl.radius : 0, this.settings.tr ? this.settings.tr.radius : 0); newMainContainer.style.height = topMaxRadius + "px"; newMainContainer.style.top = 0 - topMaxRadius + "px"; newMainContainer.style.left = 0 - this.borderWidth + "px"; this.topContainer = this.box.appendChild(newMainContainer);} +break; case 1: +if(this.settings.bl || this.settings.br) +{ var newMainContainer = document.createElement("DIV"); newMainContainer.style.width = "100%"; newMainContainer.style.fontSize = "1px"; newMainContainer.style.overflow = "hidden"; newMainContainer.style.position = "absolute"; newMainContainer.style.paddingLeft = this.borderWidth + "px"; newMainContainer.style.paddingRight = this.borderWidth + "px"; var botMaxRadius = Math.max(this.settings.bl ? this.settings.bl.radius : 0, this.settings.br ? this.settings.br.radius : 0); newMainContainer.style.height = botMaxRadius + "px"; newMainContainer.style.bottom = 0 - botMaxRadius + "px"; newMainContainer.style.left = 0 - this.borderWidth + "px"; this.bottomContainer = this.box.appendChild(newMainContainer);} +break;} +} +if(this.topContainer) this.box.style.borderTopWidth = "0px"; if(this.bottomContainer) this.box.style.borderBottomWidth = "0px"; var corners = ["tr", "tl", "br", "bl"]; for(var i in corners) +{ if(i > -1 < 4) +{ var cc = corners[i]; if(!this.settings[cc]) +{ if(((cc == "tr" || cc == "tl") && this.topContainer != null) || ((cc == "br" || cc == "bl") && this.bottomContainer != null)) +{ var newCorner = document.createElement("DIV"); newCorner.style.position = "relative"; newCorner.style.fontSize = "1px"; newCorner.style.overflow = "hidden"; if(this.backgroundImage == "") +newCorner.style.backgroundColor = this.boxColour; else +newCorner.style.backgroundImage = this.backgroundImage; switch(cc) +{ case "tl": +newCorner.style.height = topMaxRadius - this.borderWidth + "px"; newCorner.style.marginRight = this.settings.tr.radius - (this.borderWidth*2) + "px"; newCorner.style.borderLeft = this.borderString; newCorner.style.borderTop = this.borderString; newCorner.style.left = -this.borderWidth + "px"; break; case "tr": +newCorner.style.height = topMaxRadius - this.borderWidth + "px"; newCorner.style.marginLeft = this.settings.tl.radius - (this.borderWidth*2) + "px"; newCorner.style.borderRight = this.borderString; newCorner.style.borderTop = this.borderString; newCorner.style.backgroundPosition = "-" + (topMaxRadius + this.borderWidth) + "px 0px"; newCorner.style.left = this.borderWidth + "px"; break; case "bl": +newCorner.style.height = botMaxRadius - this.borderWidth + "px"; newCorner.style.marginRight = this.settings.br.radius - (this.borderWidth*2) + "px"; newCorner.style.borderLeft = this.borderString; newCorner.style.borderBottom = this.borderString; newCorner.style.left = -this.borderWidth + "px"; newCorner.style.backgroundPosition = "-" + (this.borderWidth) + "px -" + (this.boxHeight + (botMaxRadius + this.borderWidth)) + "px"; break; case "br": +newCorner.style.height = botMaxRadius - this.borderWidth + "px"; newCorner.style.marginLeft = this.settings.bl.radius - (this.borderWidth*2) + "px"; newCorner.style.borderRight = this.borderString; newCorner.style.borderBottom = this.borderString; newCorner.style.left = this.borderWidth + "px" +newCorner.style.backgroundPosition = "-" + (botMaxRadius + this.borderWidth) + "px -" + (this.boxHeight + (botMaxRadius + this.borderWidth)) + "px"; break;} +} +} +else +{ if(this.masterCorners[this.settings[cc].radius]) +{ var newCorner = this.masterCorners[this.settings[cc].radius].cloneNode(true);} +else +{ var newCorner = document.createElement("DIV"); newCorner.style.height = this.settings[cc].radius + "px"; newCorner.style.width = this.settings[cc].radius + "px"; newCorner.style.position = "absolute"; newCorner.style.fontSize = "1px"; newCorner.style.overflow = "hidden"; var borderRadius = parseInt(this.settings[cc].radius - this.borderWidth); for(var intx = 0, j = this.settings[cc].radius; intx < j; intx++) +{ if((intx +1) >= borderRadius) +var y1 = -1; else +var y1 = (Math.floor(Math.sqrt(Math.pow(borderRadius, 2) - Math.pow((intx+1), 2))) - 1); if(borderRadius != j) +{ if((intx) >= borderRadius) +var y2 = -1; else +var y2 = Math.ceil(Math.sqrt(Math.pow(borderRadius,2) - Math.pow(intx, 2))); if((intx+1) >= j) +var y3 = -1; else +var y3 = (Math.floor(Math.sqrt(Math.pow(j ,2) - Math.pow((intx+1), 2))) - 1);} +if((intx) >= j) +var y4 = -1; else +var y4 = Math.ceil(Math.sqrt(Math.pow(j ,2) - Math.pow(intx, 2))); if(y1 > -1) this.drawPixel(intx, 0, this.boxColour, 100, (y1+1), newCorner, -1, this.settings[cc].radius); if(borderRadius != j) +{ for(var inty = (y1 + 1); inty < y2; inty++) +{ if(this.settings.antiAlias) +{ if(this.backgroundImage != "") +{ var borderFract = (pixelFraction(intx, inty, borderRadius) * 100); if(borderFract < 30) +{ this.drawPixel(intx, inty, this.borderColour, 100, 1, newCorner, 0, this.settings[cc].radius);} +else +{ this.drawPixel(intx, inty, this.borderColour, 100, 1, newCorner, -1, this.settings[cc].radius);} +} +else +{ var pixelcolour = BlendColour(this.boxColour, this.borderColour, pixelFraction(intx, inty, borderRadius)); this.drawPixel(intx, inty, pixelcolour, 100, 1, newCorner, 0, this.settings[cc].radius, cc);} +} +} +if(this.settings.antiAlias) +{ if(y3 >= y2) +{ if (y2 == -1) y2 = 0; this.drawPixel(intx, y2, this.borderColour, 100, (y3 - y2 + 1), newCorner, 0, 0);} +} +else +{ if(y3 >= y1) +{ this.drawPixel(intx, (y1 + 1), this.borderColour, 100, (y3 - y1), newCorner, 0, 0);} +} +var outsideColour = this.borderColour;} +else +{ var outsideColour = this.boxColour; var y3 = y1;} +if(this.settings.antiAlias) +{ for(var inty = (y3 + 1); inty < y4; inty++) +{ this.drawPixel(intx, inty, outsideColour, (pixelFraction(intx, inty , j) * 100), 1, newCorner, ((this.borderWidth > 0)? 0 : -1), this.settings[cc].radius);} +} +} +this.masterCorners[this.settings[cc].radius] = newCorner.cloneNode(true);} +if(cc != "br") +{ for(var t = 0, k = newCorner.childNodes.length; t < k; t++) +{ var pixelBar = newCorner.childNodes[t]; var pixelBarTop = parseInt(pixelBar.style.top.substring(0, pixelBar.style.top.indexOf("px"))); var pixelBarLeft = parseInt(pixelBar.style.left.substring(0, pixelBar.style.left.indexOf("px"))); var pixelBarHeight = parseInt(pixelBar.style.height.substring(0, pixelBar.style.height.indexOf("px"))); if(cc == "tl" || cc == "bl"){ pixelBar.style.left = this.settings[cc].radius -pixelBarLeft -1 + "px";} +if(cc == "tr" || cc == "tl"){ pixelBar.style.top = this.settings[cc].radius -pixelBarHeight -pixelBarTop + "px";} +switch(cc) +{ case "tr": +pixelBar.style.backgroundPosition = "-" + Math.abs((this.boxWidth - this.settings[cc].radius + this.borderWidth) + pixelBarLeft) + "px -" + Math.abs(this.settings[cc].radius -pixelBarHeight -pixelBarTop - this.borderWidth) + "px"; break; case "tl": +pixelBar.style.backgroundPosition = "-" + Math.abs((this.settings[cc].radius -pixelBarLeft -1) - this.borderWidth) + "px -" + Math.abs(this.settings[cc].radius -pixelBarHeight -pixelBarTop - this.borderWidth) + "px"; break; case "bl": +pixelBar.style.backgroundPosition = "-" + Math.abs((this.settings[cc].radius -pixelBarLeft -1) - this.borderWidth) + "px -" + Math.abs((this.boxHeight + this.settings[cc].radius + pixelBarTop) -this.borderWidth) + "px"; break;} +} +} +} +if(newCorner) +{ switch(cc) +{ case "tl": +if(newCorner.style.position == "absolute") newCorner.style.top = "0px"; if(newCorner.style.position == "absolute") newCorner.style.left = "0px"; if(this.topContainer) this.topContainer.appendChild(newCorner); break; case "tr": +if(newCorner.style.position == "absolute") newCorner.style.top = "0px"; if(newCorner.style.position == "absolute") newCorner.style.right = "0px"; if(this.topContainer) this.topContainer.appendChild(newCorner); break; case "bl": +if(newCorner.style.position == "absolute") newCorner.style.bottom = "0px"; if(newCorner.style.position == "absolute") newCorner.style.left = "0px"; if(this.bottomContainer) this.bottomContainer.appendChild(newCorner); break; case "br": +if(newCorner.style.position == "absolute") newCorner.style.bottom = "0px"; if(newCorner.style.position == "absolute") newCorner.style.right = "0px"; if(this.bottomContainer) this.bottomContainer.appendChild(newCorner); break;} +} +} +} +var radiusDiff = new Array(); radiusDiff["t"] = Math.abs(this.settings.tl.radius - this.settings.tr.radius) +radiusDiff["b"] = Math.abs(this.settings.bl.radius - this.settings.br.radius); for(z in radiusDiff) +{ if(z == "t" || z == "b") +{ if(radiusDiff[z]) +{ var smallerCornerType = ((this.settings[z + "l"].radius < this.settings[z + "r"].radius)? z +"l" : z +"r"); var newFiller = document.createElement("DIV"); newFiller.style.height = radiusDiff[z] + "px"; newFiller.style.width = this.settings[smallerCornerType].radius+ "px" +newFiller.style.position = "absolute"; newFiller.style.fontSize = "1px"; newFiller.style.overflow = "hidden"; newFiller.style.backgroundColor = this.boxColour; switch(smallerCornerType) +{ case "tl": +newFiller.style.bottom = "0px"; newFiller.style.left = "0px"; newFiller.style.borderLeft = this.borderString; this.topContainer.appendChild(newFiller); break; case "tr": +newFiller.style.bottom = "0px"; newFiller.style.right = "0px"; newFiller.style.borderRight = this.borderString; this.topContainer.appendChild(newFiller); break; case "bl": +newFiller.style.top = "0px"; newFiller.style.left = "0px"; newFiller.style.borderLeft = this.borderString; this.bottomContainer.appendChild(newFiller); break; case "br": +newFiller.style.top = "0px"; newFiller.style.right = "0px"; newFiller.style.borderRight = this.borderString; this.bottomContainer.appendChild(newFiller); break;} +} +var newFillerBar = document.createElement("DIV"); newFillerBar.style.position = "relative"; newFillerBar.style.fontSize = "1px"; newFillerBar.style.overflow = "hidden"; newFillerBar.style.backgroundColor = this.boxColour; newFillerBar.style.backgroundImage = this.backgroundImage; switch(z) +{ case "t": +if(this.topContainer) +{ if(this.settings.tl.radius && this.settings.tr.radius) +{ newFillerBar.style.height = topMaxRadius - this.borderWidth + "px"; newFillerBar.style.marginLeft = this.settings.tl.radius - this.borderWidth + "px"; newFillerBar.style.marginRight = this.settings.tr.radius - this.borderWidth + "px"; newFillerBar.style.borderTop = this.borderString; if(this.backgroundImage != "") +newFillerBar.style.backgroundPosition = "-" + (topMaxRadius + this.borderWidth) + "px 0px"; this.topContainer.appendChild(newFillerBar);} +this.box.style.backgroundPosition = "0px -" + (topMaxRadius - this.borderWidth) + "px";} +break; case "b": +if(this.bottomContainer) +{ if(this.settings.bl.radius && this.settings.br.radius) +{ newFillerBar.style.height = botMaxRadius - this.borderWidth + "px"; newFillerBar.style.marginLeft = this.settings.bl.radius - this.borderWidth + "px"; newFillerBar.style.marginRight = this.settings.br.radius - this.borderWidth + "px"; newFillerBar.style.borderBottom = this.borderString; if(this.backgroundImage != "") +newFillerBar.style.backgroundPosition = "-" + (botMaxRadius + this.borderWidth) + "px -" + (this.boxHeight + (topMaxRadius + this.borderWidth)) + "px"; this.bottomContainer.appendChild(newFillerBar);} +} +break;} +} +} +if(this.settings.autoPad == true && this.boxPadding > 0) +{ var contentContainer = document.createElement("DIV"); contentContainer.style.position = "relative"; contentContainer.innerHTML = this.boxContent; contentContainer.className = "autoPadDiv"; var topPadding = Math.abs(topMaxRadius - this.boxPadding); var botPadding = Math.abs(botMaxRadius - this.boxPadding); if(topMaxRadius < this.boxPadding) +contentContainer.style.paddingTop = topPadding + "px"; if(botMaxRadius < this.boxPadding) +contentContainer.style.paddingBottom = botMaxRadius + "px"; contentContainer.style.paddingLeft = this.boxPadding + "px"; contentContainer.style.paddingRight = this.boxPadding + "px"; this.contentDIV = this.box.appendChild(contentContainer);} +} +this.drawPixel = function(intx, inty, colour, transAmount, height, newCorner, image, cornerRadius) +{ var pixel = document.createElement("DIV"); pixel.style.height = height + "px"; pixel.style.width = "1px"; pixel.style.position = "absolute"; pixel.style.fontSize = "1px"; pixel.style.overflow = "hidden"; var topMaxRadius = Math.max(this.settings["tr"].radius, this.settings["tl"].radius); if(image == -1 && this.backgroundImage != "") +{ pixel.style.backgroundImage = this.backgroundImage; pixel.style.backgroundPosition = "-" + (this.boxWidth - (cornerRadius - intx) + this.borderWidth) + "px -" + ((this.boxHeight + topMaxRadius + inty) -this.borderWidth) + "px";} +else +{ pixel.style.backgroundColor = colour;} +if (transAmount != 100) +setOpacity(pixel, transAmount); pixel.style.top = inty + "px"; pixel.style.left = intx + "px"; newCorner.appendChild(pixel);} +} +function insertAfter(parent, node, referenceNode) +{ parent.insertBefore(node, referenceNode.nextSibling);} +function BlendColour(Col1, Col2, Col1Fraction) +{ var red1 = parseInt(Col1.substr(1,2),16); var green1 = parseInt(Col1.substr(3,2),16); var blue1 = parseInt(Col1.substr(5,2),16); var red2 = parseInt(Col2.substr(1,2),16); var green2 = parseInt(Col2.substr(3,2),16); var blue2 = parseInt(Col2.substr(5,2),16); if(Col1Fraction > 1 || Col1Fraction < 0) Col1Fraction = 1; var endRed = Math.round((red1 * Col1Fraction) + (red2 * (1 - Col1Fraction))); if(endRed > 255) endRed = 255; if(endRed < 0) endRed = 0; var endGreen = Math.round((green1 * Col1Fraction) + (green2 * (1 - Col1Fraction))); if(endGreen > 255) endGreen = 255; if(endGreen < 0) endGreen = 0; var endBlue = Math.round((blue1 * Col1Fraction) + (blue2 * (1 - Col1Fraction))); if(endBlue > 255) endBlue = 255; if(endBlue < 0) endBlue = 0; return "#" + IntToHex(endRed)+ IntToHex(endGreen)+ IntToHex(endBlue);} +function IntToHex(strNum) +{ base = strNum / 16; rem = strNum % 16; base = base - (rem / 16); baseS = MakeHex(base); remS = MakeHex(rem); return baseS + '' + remS;} +function MakeHex(x) +{ if((x >= 0) && (x <= 9)) +{ return x;} +else +{ switch(x) +{ case 10: return "A"; case 11: return "B"; case 12: return "C"; case 13: return "D"; case 14: return "E"; case 15: return "F";} +} +} +function pixelFraction(x, y, r) +{ var pixelfraction = 0; var xvalues = new Array(1); var yvalues = new Array(1); var point = 0; var whatsides = ""; var intersect = Math.sqrt((Math.pow(r,2) - Math.pow(x,2))); if ((intersect >= y) && (intersect < (y+1))) +{ whatsides = "Left"; xvalues[point] = 0; yvalues[point] = intersect - y; point = point + 1;} +var intersect = Math.sqrt((Math.pow(r,2) - Math.pow(y+1,2))); if ((intersect >= x) && (intersect < (x+1))) +{ whatsides = whatsides + "Top"; xvalues[point] = intersect - x; yvalues[point] = 1; point = point + 1;} +var intersect = Math.sqrt((Math.pow(r,2) - Math.pow(x+1,2))); if ((intersect >= y) && (intersect < (y+1))) +{ whatsides = whatsides + "Right"; xvalues[point] = 1; yvalues[point] = intersect - y; point = point + 1;} +var intersect = Math.sqrt((Math.pow(r,2) - Math.pow(y,2))); if ((intersect >= x) && (intersect < (x+1))) +{ whatsides = whatsides + "Bottom"; xvalues[point] = intersect - x; yvalues[point] = 0;} +switch (whatsides) +{ case "LeftRight": +pixelfraction = Math.min(yvalues[0],yvalues[1]) + ((Math.max(yvalues[0],yvalues[1]) - Math.min(yvalues[0],yvalues[1]))/2); break; case "TopRight": +pixelfraction = 1-(((1-xvalues[0])*(1-yvalues[1]))/2); break; case "TopBottom": +pixelfraction = Math.min(xvalues[0],xvalues[1]) + ((Math.max(xvalues[0],xvalues[1]) - Math.min(xvalues[0],xvalues[1]))/2); break; case "LeftBottom": +pixelfraction = (yvalues[0]*xvalues[1])/2; break; default: +pixelfraction = 1;} +return pixelfraction;} +function rgb2Hex(rgbColour) +{ try{ var rgbArray = rgb2Array(rgbColour); var red = parseInt(rgbArray[0]); var green = parseInt(rgbArray[1]); var blue = parseInt(rgbArray[2]); var hexColour = "#" + IntToHex(red) + IntToHex(green) + IntToHex(blue);} +catch(e){ alert("There was an error converting the RGB value to Hexadecimal in function rgb2Hex");} +return hexColour;} +function rgb2Array(rgbColour) +{ var rgbValues = rgbColour.substring(4, rgbColour.indexOf(")")); var rgbArray = rgbValues.split(", "); return rgbArray;} +function setOpacity(obj, opacity) +{ opacity = (opacity == 100)?99.999:opacity; if(isSafari && obj.tagName != "IFRAME") +{ var rgbArray = rgb2Array(obj.style.backgroundColor); var red = parseInt(rgbArray[0]); var green = parseInt(rgbArray[1]); var blue = parseInt(rgbArray[2]); obj.style.backgroundColor = "rgba(" + red + ", " + green + ", " + blue + ", " + opacity/100 + ")";} +else if(typeof(obj.style.opacity) != "undefined") +{ obj.style.opacity = opacity/100;} +else if(typeof(obj.style.MozOpacity) != "undefined") +{ obj.style.MozOpacity = opacity/100;} +else if(typeof(obj.style.filter) != "undefined") +{ obj.style.filter = "alpha(opacity:" + opacity + ")";} +else if(typeof(obj.style.KHTMLOpacity) != "undefined") +{ obj.style.KHTMLOpacity = opacity/100;} +} +function inArray(array, value) +{ for(var i = 0; i < array.length; i++){ if (array[i] === value) return i;} +return false;} +function inArrayKey(array, value) +{ for(key in array){ if(key === value) return true;} +return false;} +function addEvent(elm, evType, fn, useCapture) { if (elm.addEventListener) { elm.addEventListener(evType, fn, useCapture); return true;} +else if (elm.attachEvent) { var r = elm.attachEvent('on' + evType, fn); return r;} +else { elm['on' + evType] = fn;} +} +function removeEvent(obj, evType, fn, useCapture){ if (obj.removeEventListener){ obj.removeEventListener(evType, fn, useCapture); return true;} else if (obj.detachEvent){ var r = obj.detachEvent("on"+evType, fn); return r;} else { alert("Handler could not be removed");} +} +function format_colour(colour) +{ var returnColour = "#ffffff"; if(colour != "" && colour != "transparent") +{ if(colour.substr(0, 3) == "rgb") +{ returnColour = rgb2Hex(colour);} +else if(colour.length == 4) +{ returnColour = "#" + colour.substring(1, 2) + colour.substring(1, 2) + colour.substring(2, 3) + colour.substring(2, 3) + colour.substring(3, 4) + colour.substring(3, 4);} +else +{ returnColour = colour;} +} +return returnColour;} +function get_style(obj, property, propertyNS) +{ try +{ if(obj.currentStyle) +{ var returnVal = eval("obj.currentStyle." + property);} +else +{ if(isSafari && obj.style.display == "none") +{ obj.style.display = ""; var wasHidden = true;} +var returnVal = document.defaultView.getComputedStyle(obj, '').getPropertyValue(propertyNS); if(isSafari && wasHidden) +{ obj.style.display = "none";} +} +} +catch(e) +{ } +return returnVal;} +function getElementsByClass(searchClass, node, tag) +{ var classElements = new Array(); if(node == null) +node = document; if(tag == null) +tag = '*'; var els = node.getElementsByTagName(tag); var elsLen = els.length; var pattern = new RegExp("(^|\s)"+searchClass+"(\s|$)"); for (i = 0, j = 0; i < elsLen; i++) +{ if(pattern.test(els[i].className)) +{ classElements[j] = els[i]; j++;} +} +return classElements;} +function newCurvyError(errorMessage) +{ return new Error("curvyCorners Error:\n" + errorMessage) +} diff --git a/vendor/gems/composite_primary_keys-1.1.0/website/stylesheets/screen.css b/vendor/gems/composite_primary_keys-1.1.0/website/stylesheets/screen.css new file mode 100644 index 000000000..3f2d8f951 --- /dev/null +++ b/vendor/gems/composite_primary_keys-1.1.0/website/stylesheets/screen.css @@ -0,0 +1,126 @@ +body { + background-color: #2F30EE; + font-family: "Georgia", sans-serif; + font-size: 16px; + line-height: 1.6em; + padding: 1.6em 0 0 0; + color: #eee; +} +h1, h2, h3, h4, h5, h6 { + color: #FFEDFA; +} +h1 { + font-family: sans-serif; + font-weight: normal; + font-size: 4em; + line-height: 0.8em; + letter-spacing: -0.1ex; + margin: 5px; +} +li { + padding: 0; + margin: 0; + list-style-type: square; +} +a { + color: #99f; + font-weight: normal; + text-decoration: underline; +} +blockquote { + font-size: 90%; + font-style: italic; + border-left: 1px solid #eee; + padding-left: 1em; +} +.caps { + font-size: 80%; +} + +#main { + width: 45em; + padding: 0; + margin: 0 auto; +} +.coda { + text-align: right; + color: #77f; + font-size: smaller; +} + +table { + font-size: 90%; + line-height: 1.4em; + color: #ff8; + background-color: #111; + padding: 2px 10px 2px 10px; + border-style: dashed; +} + +th { + color: #fff; +} + +td { + padding: 2px 10px 2px 10px; +} + +.success { + color: #0CC52B; +} + +.failed { + color: #E90A1B; +} + +.unknown { + color: #995000; +} +pre, code { + font-family: monospace; + font-size: 90%; + line-height: 1.4em; + color: #ff8; + background-color: #111; + padding: 2px 10px 2px 10px; +} +.comment { color: #aaa; font-style: italic; } +.keyword { color: #eff; font-weight: bold; } +.punct { color: #eee; font-weight: bold; } +.symbol { color: #0bb; } +.string { color: #6b4; } +.ident { color: #ff8; } +.constant { color: #66f; } +.regex { color: #ec6; } +.number { color: #F99; } +.expr { color: #227; } + +#version { + float: right; + text-align: right; + font-family: sans-serif; + font-weight: normal; + background-color: #ff8; + color: #66f; + padding: 15px 20px 10px 20px; + margin: 0 auto; + margin-top: 15px; + border: 3px solid #66f; +} + +#version .numbers { + display: block; + font-size: 4em; + line-height: 0.8em; + letter-spacing: -0.1ex; +} + +#version a { + text-decoration: none; +} + +.clickable { + cursor: pointer; + cursor: hand; +} + diff --git a/vendor/gems/composite_primary_keys-1.1.0/website/template.js b/vendor/gems/composite_primary_keys-1.1.0/website/template.js new file mode 100644 index 000000000..fbaf5a5e8 --- /dev/null +++ b/vendor/gems/composite_primary_keys-1.1.0/website/template.js @@ -0,0 +1,3 @@ +// <%= title %> +var version = <%= version.to_json %>; +<%= body %> diff --git a/vendor/gems/composite_primary_keys-1.1.0/website/template.rhtml b/vendor/gems/composite_primary_keys-1.1.0/website/template.rhtml new file mode 100644 index 000000000..3e2c531c0 --- /dev/null +++ b/vendor/gems/composite_primary_keys-1.1.0/website/template.rhtml @@ -0,0 +1,53 @@ + + + + + + + <%= title %> + + + + + + +
+ +

<%= title %>

+
+ Get Version + <%= version %> +
+ <%= body %> +

+ Dr Nic, <%= modified.pretty %>
+ Theme extended from Paul Battley +

+
+ + + + + + diff --git a/vendor/gems/composite_primary_keys-1.1.0/website/version-raw.js b/vendor/gems/composite_primary_keys-1.1.0/website/version-raw.js new file mode 100644 index 000000000..9d2ac788f --- /dev/null +++ b/vendor/gems/composite_primary_keys-1.1.0/website/version-raw.js @@ -0,0 +1,3 @@ +// Announcement JS file +var version = "1.1.0"; +MagicAnnouncement.show('compositekeys', version); diff --git a/vendor/gems/composite_primary_keys-1.1.0/website/version-raw.txt b/vendor/gems/composite_primary_keys-1.1.0/website/version-raw.txt new file mode 100644 index 000000000..74ca3ac67 --- /dev/null +++ b/vendor/gems/composite_primary_keys-1.1.0/website/version-raw.txt @@ -0,0 +1,2 @@ +h1. Announcement JS file +MagicAnnouncement.show('compositekeys', version); \ No newline at end of file diff --git a/vendor/gems/composite_primary_keys-1.1.0/website/version.js b/vendor/gems/composite_primary_keys-1.1.0/website/version.js new file mode 100644 index 000000000..56921a962 --- /dev/null +++ b/vendor/gems/composite_primary_keys-1.1.0/website/version.js @@ -0,0 +1,4 @@ +// Version JS file +var version = "1.1.0"; + +document.write(" - " + version); diff --git a/vendor/gems/composite_primary_keys-1.1.0/website/version.txt b/vendor/gems/composite_primary_keys-1.1.0/website/version.txt new file mode 100644 index 000000000..d0ac6a7ac --- /dev/null +++ b/vendor/gems/composite_primary_keys-1.1.0/website/version.txt @@ -0,0 +1,3 @@ +h1. Version JS file + +document.write(" - " + version); \ No newline at end of file diff --git a/vendor/plugins/classic_pagination/lib/pagination.rb b/vendor/plugins/classic_pagination/lib/pagination.rb index b6e9cf4bc..6a3e1a97b 100644 --- a/vendor/plugins/classic_pagination/lib/pagination.rb +++ b/vendor/plugins/classic_pagination/lib/pagination.rb @@ -97,8 +97,8 @@ module ActionController "Unknown options: #{unknown_option_keys.join(', ')}" unless unknown_option_keys.empty? - options[:singular_name] ||= Inflector.singularize(collection_id.to_s) - options[:class_name] ||= Inflector.camelize(options[:singular_name]) + options[:singular_name] ||= ActiveSupport::Inflector.singularize(collection_id.to_s) + options[:class_name] ||= ActiveSupport::Inflector.camelize(options[:singular_name]) end # Returns a paginator and a collection of Active Record model instances diff --git a/vendor/plugins/deadlock_retry/README b/vendor/plugins/deadlock_retry/README new file mode 100644 index 000000000..b5937ce0e --- /dev/null +++ b/vendor/plugins/deadlock_retry/README @@ -0,0 +1,10 @@ +Deadlock Retry +============== + +Deadlock retry allows the database adapter (currently only tested with the +MySQLAdapter) to retry transactions that fall into deadlock. It will retry +such transactions three times before finally failing. + +This capability is automatically added to ActiveRecord. No code changes or otherwise are required. + +Copyright (c) 2005 Jamis Buck, released under the MIT license \ No newline at end of file diff --git a/vendor/plugins/deadlock_retry/Rakefile b/vendor/plugins/deadlock_retry/Rakefile new file mode 100644 index 000000000..8063a6ed4 --- /dev/null +++ b/vendor/plugins/deadlock_retry/Rakefile @@ -0,0 +1,10 @@ +require 'rake' +require 'rake/testtask' + +desc "Default task" +task :default => [ :test ] + +Rake::TestTask.new do |t| + t.test_files = Dir["test/**/*_test.rb"] + t.verbose = true +end diff --git a/vendor/plugins/deadlock_retry/init.rb b/vendor/plugins/deadlock_retry/init.rb new file mode 100644 index 000000000..e090f68af --- /dev/null +++ b/vendor/plugins/deadlock_retry/init.rb @@ -0,0 +1,2 @@ +require 'deadlock_retry' +ActiveRecord::Base.send :include, DeadlockRetry diff --git a/vendor/plugins/deadlock_retry/lib/deadlock_retry.rb b/vendor/plugins/deadlock_retry/lib/deadlock_retry.rb new file mode 100644 index 000000000..413cb823c --- /dev/null +++ b/vendor/plugins/deadlock_retry/lib/deadlock_retry.rb @@ -0,0 +1,58 @@ +# Copyright (c) 2005 Jamis Buck +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +module DeadlockRetry + def self.append_features(base) + super + base.extend(ClassMethods) + base.class_eval do + class < error + if DEADLOCK_ERROR_MESSAGES.any? { |msg| error.message =~ /#{Regexp.escape(msg)}/ } + raise if retry_count >= MAXIMUM_RETRIES_ON_DEADLOCK + retry_count += 1 + logger.info "Deadlock detected on retry #{retry_count}, restarting transaction" + retry + else + raise + end + end + end + end +end diff --git a/vendor/plugins/deadlock_retry/test/deadlock_retry_test.rb b/vendor/plugins/deadlock_retry/test/deadlock_retry_test.rb new file mode 100644 index 000000000..db0f6195d --- /dev/null +++ b/vendor/plugins/deadlock_retry/test/deadlock_retry_test.rb @@ -0,0 +1,65 @@ +begin + require 'active_record' +rescue LoadError + if ENV['ACTIVERECORD_PATH'].nil? + abort < true option. + * added support for file_column enabled unit tests [Manuel Holtgrewe] + * support for custom transformation of images [Frederik Fix] + * allow setting of image attributes (e.g., quality) [Frederik Fix] + * :magick columns can optionally ignore non-images (i.e., do not try to + resize them) + +0.3.1 + * make object with file_columns serializable + * use normal require for RMagick, so that it works with gem + and custom install as well + +0.3 + * fixed bug where empty file uploads were not recognized with some browsers + * fixed bug on windows when "file" utility is not present + * added option to disable automatic file extension correction + * Only allow one attribute per call to file_column, so that options only + apply to one argument + * try to detect when people forget to set the form encoding to + 'multipart/form-data' + * converted to rails plugin + * easy integration with RMagick + +0.2 + * complete rewrite using state pattern + * fixed sanitize filename [Michael Raidel] + * fixed bug when no file was uploaded [Michael Raidel] + * try to fix filename extensions [Michael Raidel] + * Feed absolute paths through File.expand_path to make them as simple as possible + * Make file_column_field helper work with auto-ids (e.g., "event[]") + +0.1.3 + * test cases with more than 1 file_column + * fixed bug when file_column was called with several arguments + * treat empty ("") file_columns as nil + * support for binary files on windows + +0.1.2 + * better rails integration, so that you do not have to include the modules yourself. You + just have to "require 'rails_file_column'" in your "config/environment.rb" + * Rakefile for testing and packaging + +0.1.1 (2005-08-11) + * fixed nasty bug in url_for_file_column that made it unusable on Apache + * prepared for public release + +0.1 (2005-08-10) + * initial release diff --git a/vendor/plugins/file_column/README b/vendor/plugins/file_column/README new file mode 100644 index 000000000..07a6e9661 --- /dev/null +++ b/vendor/plugins/file_column/README @@ -0,0 +1,54 @@ +FEATURES +======== + +Let's assume an model class named Entry, where we want to define the "image" column +as a "file_upload" column. + +class Entry < ActiveRecord::Base + file_column :image +end + +* every entry can have one uploaded file, the filename will be stored in the "image" column + +* files will be stored in "public/entry/image//filename.ext" + +* Newly uploaded files will be stored in "public/entry/tmp//filename.ext" so that + they can be reused in form redisplays (due to validation etc.) + +* in a view, "<%= file_column_field 'entry', 'image' %> will create a file upload field as well + as a hidden field to recover files uploaded before in a case of a form redisplay + +* in a view, "<%= url_for_file_column 'entry', 'image' %> will create an URL to access the + uploaded file. Note that you need an Entry object in the instance variable @entry for this + to work. + +* easy integration with RMagick to resize images and/or create thumb-nails. + +USAGE +===== + +Just drop the whole directory into your application's "vendor/plugins" directory. Starting +with version 1.0rc of rails, it will be automatically picked for you by rails plugin +mechanism. + +DOCUMENTATION +============= + +Please look at the rdoc-generated documentation in the "doc" directory. + +RUNNING UNITTESTS +================= + +There are extensive unittests in the "test" directory. Currently, only MySQL is supported, but +you should be able to easily fix this by looking at "connection.rb". You have to create a +database for the tests and put the connection information into "connection.rb". The schema +for MySQL can be found in "test/fixtures/mysql.sql". + +You can run the tests by starting the "*_test.rb" in the directory "test" + +BUGS & FEEDBACK +=============== + +Bug reports (as well as patches) and feedback are very welcome. Please send it to +sebastian.kanthak@muehlheim.de + diff --git a/vendor/plugins/file_column/Rakefile b/vendor/plugins/file_column/Rakefile new file mode 100644 index 000000000..0a2468248 --- /dev/null +++ b/vendor/plugins/file_column/Rakefile @@ -0,0 +1,36 @@ +task :default => [:test] + +PKG_NAME = "file-column" +PKG_VERSION = "0.3.1" + +PKG_DIR = "release/#{PKG_NAME}-#{PKG_VERSION}" + +task :clean do + rm_rf "release" +end + +task :setup_directories do + mkpath "release" +end + + +task :checkout_release => :setup_directories do + rm_rf PKG_DIR + revision = ENV["REVISION"] || "HEAD" + sh "svn export -r #{revision} . #{PKG_DIR}" +end + +task :release_docs => :checkout_release do + sh "cd #{PKG_DIR}; rdoc lib" +end + +task :package => [:checkout_release, :release_docs] do + sh "cd release; tar czf #{PKG_NAME}-#{PKG_VERSION}.tar.gz #{PKG_NAME}-#{PKG_VERSION}" +end + +task :test do + sh "cd test; ruby file_column_test.rb" + sh "cd test; ruby file_column_helper_test.rb" + sh "cd test; ruby magick_test.rb" + sh "cd test; ruby magick_view_only_test.rb" +end diff --git a/vendor/plugins/file_column/TODO b/vendor/plugins/file_column/TODO new file mode 100644 index 000000000..d46e9fa80 --- /dev/null +++ b/vendor/plugins/file_column/TODO @@ -0,0 +1,6 @@ +* document configuration options better +* support setting of permissions +* validation methods for file format/size +* delete stale files from tmp directories + +* ensure valid URLs are created even when deployed at sub-path (compute_public_url?) diff --git a/vendor/plugins/file_column/init.rb b/vendor/plugins/file_column/init.rb new file mode 100644 index 000000000..d31ef1b9c --- /dev/null +++ b/vendor/plugins/file_column/init.rb @@ -0,0 +1,13 @@ +# plugin init file for rails +# this file will be picked up by rails automatically and +# add the file_column extensions to rails + +require 'file_column' +require 'file_compat' +require 'file_column_helper' +require 'validations' +require 'test_case' + +ActiveRecord::Base.send(:include, FileColumn) +ActionView::Base.send(:include, FileColumnHelper) +ActiveRecord::Base.send(:include, FileColumn::Validations) \ No newline at end of file diff --git a/vendor/plugins/file_column/lib/file_column.rb b/vendor/plugins/file_column/lib/file_column.rb new file mode 100644 index 000000000..791a5be3e --- /dev/null +++ b/vendor/plugins/file_column/lib/file_column.rb @@ -0,0 +1,720 @@ +require 'fileutils' +require 'tempfile' +require 'magick_file_column' + +module FileColumn # :nodoc: + def self.append_features(base) + super + base.extend(ClassMethods) + end + + def self.create_state(instance,attr) + filename = instance[attr] + if filename.nil? or filename.empty? + NoUploadedFile.new(instance,attr) + else + PermanentUploadedFile.new(instance,attr) + end + end + + def self.init_options(defaults, model, attr) + options = defaults.dup + options[:store_dir] ||= File.join(options[:root_path], model, attr) + unless options[:store_dir].is_a?(Symbol) + options[:tmp_base_dir] ||= File.join(options[:store_dir], "tmp") + end + options[:base_url] ||= options[:web_root] + File.join(model, attr) + + [:store_dir, :tmp_base_dir].each do |dir_sym| + if options[dir_sym].is_a?(String) and !File.exists?(options[dir_sym]) + FileUtils.mkpath(options[dir_sym]) + end + end + + options + end + + class BaseUploadedFile # :nodoc: + + def initialize(instance,attr) + @instance, @attr = instance, attr + @options_method = "#{attr}_options".to_sym + end + + + def assign(file) + if file.is_a? File + # this did not come in via a CGI request. However, + # assigning files directly may be useful, so we + # make just this file object similar enough to an uploaded + # file that we can handle it. + file.extend FileColumn::FileCompat + end + + if file.nil? + delete + else + if file.size == 0 + # user did not submit a file, so we + # can simply ignore this + self + else + if file.is_a?(String) + # if file is a non-empty string it is most probably + # the filename and the user forgot to set the encoding + # to multipart/form-data. Since we would raise an exception + # because of the missing "original_filename" method anyways, + # we raise a more meaningful exception rightaway. + raise TypeError.new("Do not know how to handle a string with value '#{file}' that was passed to a file_column. Check if the form's encoding has been set to 'multipart/form-data'.") + end + upload(file) + end + end + end + + def just_uploaded? + @just_uploaded + end + + def on_save(&blk) + @on_save ||= [] + @on_save << Proc.new + end + + # the following methods are overriden by sub-classes if needed + + def temp_path + nil + end + + def absolute_dir + if absolute_path then File.dirname(absolute_path) else nil end + end + + def relative_dir + if relative_path then File.dirname(relative_path) else nil end + end + + def after_save + @on_save.each { |blk| blk.call } if @on_save + self + end + + def after_destroy + end + + def options + @instance.send(@options_method) + end + + private + + def store_dir + if options[:store_dir].is_a? Symbol + raise ArgumentError.new("'#{options[:store_dir]}' is not an instance method of class #{@instance.class.name}") unless @instance.respond_to?(options[:store_dir]) + + dir = File.join(options[:root_path], @instance.send(options[:store_dir])) + FileUtils.mkpath(dir) unless File.exists?(dir) + dir + else + options[:store_dir] + end + end + + def tmp_base_dir + if options[:tmp_base_dir] + options[:tmp_base_dir] + else + dir = File.join(store_dir, "tmp") + FileUtils.mkpath(dir) unless File.exists?(dir) + dir + end + end + + def clone_as(klass) + klass.new(@instance, @attr) + end + + end + + + class NoUploadedFile < BaseUploadedFile # :nodoc: + def delete + # we do not have a file so deleting is easy + self + end + + def upload(file) + # replace ourselves with a TempUploadedFile + temp = clone_as TempUploadedFile + temp.store_upload(file) + temp + end + + def absolute_path(subdir=nil) + nil + end + + + def relative_path(subdir=nil) + nil + end + + def assign_temp(temp_path) + return self if temp_path.nil? or temp_path.empty? + temp = clone_as TempUploadedFile + temp.parse_temp_path temp_path + temp + end + end + + class RealUploadedFile < BaseUploadedFile # :nodoc: + def absolute_path(subdir=nil) + if subdir + File.join(@dir, subdir, @filename) + else + File.join(@dir, @filename) + end + end + + def relative_path(subdir=nil) + if subdir + File.join(relative_path_prefix, subdir, @filename) + else + File.join(relative_path_prefix, @filename) + end + end + + private + + # regular expressions to try for identifying extensions + EXT_REGEXPS = [ + /^(.+)\.([^.]+\.[^.]+)$/, # matches "something.tar.gz" + /^(.+)\.([^.]+)$/ # matches "something.jpg" + ] + + def split_extension(filename,fallback=nil) + EXT_REGEXPS.each do |regexp| + if filename =~ regexp + base,ext = $1, $2 + return [base, ext] if options[:extensions].include?(ext.downcase) + end + end + if fallback and filename =~ EXT_REGEXPS.last + return [$1, $2] + end + [filename, ""] + end + + end + + class TempUploadedFile < RealUploadedFile # :nodoc: + + def store_upload(file) + @tmp_dir = FileColumn.generate_temp_name + @dir = File.join(tmp_base_dir, @tmp_dir) + FileUtils.mkdir(@dir) + + @filename = FileColumn::sanitize_filename(file.original_filename) + local_file_path = File.join(tmp_base_dir,@tmp_dir,@filename) + + # stored uploaded file into local_file_path + # If it was a Tempfile object, the temporary file will be + # cleaned up automatically, so we do not have to care for this + if file.respond_to?(:local_path) and file.local_path and File.exists?(file.local_path) + FileUtils.copy_file(file.local_path, local_file_path) + elsif file.respond_to?(:read) + File.open(local_file_path, "wb") { |f| f.write(file.read) } + else + raise ArgumentError.new("Do not know how to handle #{file.inspect}") + end + File.chmod(options[:permissions], local_file_path) + + if options[:fix_file_extensions] + # try to determine correct file extension and fix + # if necessary + content_type = get_content_type((file.content_type.chomp if file.content_type)) + if content_type and options[:mime_extensions][content_type] + @filename = correct_extension(@filename,options[:mime_extensions][content_type]) + end + + new_local_file_path = File.join(tmp_base_dir,@tmp_dir,@filename) + File.rename(local_file_path, new_local_file_path) unless new_local_file_path == local_file_path + local_file_path = new_local_file_path + end + + @instance[@attr] = @filename + @just_uploaded = true + end + + + # tries to identify and strip the extension of filename + # if an regular expresion from EXT_REGEXPS matches and the + # downcased extension is a known extension (in options[:extensions]) + # we'll strip this extension + def strip_extension(filename) + split_extension(filename).first + end + + def correct_extension(filename, ext) + strip_extension(filename) << ".#{ext}" + end + + def parse_temp_path(temp_path, instance_options=nil) + raise ArgumentError.new("invalid format of '#{temp_path}'") unless temp_path =~ %r{^((\d+\.)+\d+)/([^/].+)$} + @tmp_dir, @filename = $1, FileColumn.sanitize_filename($3) + @dir = File.join(tmp_base_dir, @tmp_dir) + + @instance[@attr] = @filename unless instance_options == :ignore_instance + end + + def upload(file) + # store new file + temp = clone_as TempUploadedFile + temp.store_upload(file) + + # delete old copy + delete_files + + # and return new TempUploadedFile object + temp + end + + def delete + delete_files + @instance[@attr] = "" + clone_as NoUploadedFile + end + + def assign_temp(temp_path) + return self if temp_path.nil? or temp_path.empty? + # we can ignore this since we've already received a newly uploaded file + + # however, we delete the old temporary files + temp = clone_as TempUploadedFile + temp.parse_temp_path(temp_path, :ignore_instance) + temp.delete_files + + self + end + + def temp_path + File.join(@tmp_dir, @filename) + end + + def after_save + super + + # we have a newly uploaded image, move it to the correct location + file = clone_as PermanentUploadedFile + file.move_from(File.join(tmp_base_dir, @tmp_dir), @just_uploaded) + + # delete temporary files + delete_files + + # replace with the new PermanentUploadedFile object + file + end + + def delete_files + FileUtils.rm_rf(File.join(tmp_base_dir, @tmp_dir)) + end + + def get_content_type(fallback=nil) + if options[:file_exec] + begin + content_type = `#{options[:file_exec]} -bi "#{File.join(@dir,@filename)}"`.chomp + content_type = fallback unless $?.success? + content_type.gsub!(/;.+$/,"") if content_type + content_type + rescue + fallback + end + else + fallback + end + end + + private + + def relative_path_prefix + File.join("tmp", @tmp_dir) + end + end + + + class PermanentUploadedFile < RealUploadedFile # :nodoc: + def initialize(*args) + super *args + @dir = File.join(store_dir, relative_path_prefix) + @filename = @instance[@attr] + @filename = nil if @filename.empty? + end + + def move_from(local_dir, just_uploaded) + # remove old permament dir first + # this creates a short moment, where neither the old nor + # the new files exist but we can't do much about this as + # filesystems aren't transactional. + FileUtils.rm_rf @dir + + FileUtils.mv local_dir, @dir + + @just_uploaded = just_uploaded + end + + def upload(file) + temp = clone_as TempUploadedFile + temp.store_upload(file) + temp + end + + def delete + file = clone_as NoUploadedFile + @instance[@attr] = "" + file.on_save { delete_files } + file + end + + def assign_temp(temp_path) + return nil if temp_path.nil? or temp_path.empty? + + temp = clone_as TempUploadedFile + temp.parse_temp_path(temp_path) + temp + end + + def after_destroy + delete_files + end + + def delete_files + FileUtils.rm_rf @dir + end + + private + + def relative_path_prefix + raise RuntimeError.new("Trying to access file_column, but primary key got lost.") if @instance.id.to_s.empty? + @instance.id.to_s + end + end + + # The FileColumn module allows you to easily handle file uploads. You can designate + # one or more columns of your model's table as "file columns" like this: + # + # class Entry < ActiveRecord::Base + # + # file_column :image + # end + # + # Now, by default, an uploaded file "test.png" for an entry object with primary key 42 will + # be stored in in "public/entry/image/42/test.png". The filename "test.png" will be stored + # in the record's "image" column. The "entries" table should have a +VARCHAR+ column + # named "image". + # + # The methods of this module are automatically included into ActiveRecord::Base + # as class methods, so that you can use them in your models. + # + # == Generated Methods + # + # After calling "file_column :image" as in the example above, a number of instance methods + # will automatically be generated, all prefixed by "image": + # + # * Entry#image=(uploaded_file): this will handle a newly uploaded file + # (see below). Note that + # you can simply call your upload field "entry[image]" in your view (or use the + # helper). + # * Entry#image(subdir=nil): This will return an absolute path (as a + # string) to the currently uploaded file + # or nil if no file has been uploaded + # * Entry#image_relative_path(subdir=nil): This will return a path relative to + # this file column's base directory + # as a string or nil if no file has been uploaded. This would be "42/test.png" in the example. + # * Entry#image_just_uploaded?: Returns true if a new file has been uploaded to this instance. + # You can use this in your code to perform certain actions (e. g., validation, + # custom post-processing) only on newly uploaded files. + # + # You can access the raw value of the "image" column (which will contain the filename) via the + # ActiveRecord::Base#attributes or ActiveRecord::Base#[] methods like this: + # + # entry['image'] # e.g."test.png" + # + # == Storage of uploaded files + # + # For a model class +Entry+ and a column +image+, all files will be stored under + # "public/entry/image". A sub-directory named after the primary key of the object will + # be created, so that files can be stored using their real filename. For example, a file + # "test.png" stored in an Entry object with id 42 will be stored in + # + # public/entry/image/42/test.png + # + # Files will be moved to this location in an +after_save+ callback. They will be stored in + # a temporary location previously as explained in the next section. + # + # By default, files will be created with unix permissions of 0644 (i. e., owner has + # read/write access, group and others only have read access). You can customize + # this by passing the desired mode as a :permissions options. The value + # you give here is passed directly to File::chmod, so on Unix you should + # give some octal value like 0644, for example. + # + # == Handling of form redisplay + # + # Suppose you have a form for creating a new object where the user can upload an image. The form may + # have to be re-displayed because of validation errors. The uploaded file has to be stored somewhere so + # that the user does not have to upload it again. FileColumn will store these in a temporary directory + # (called "tmp" and located under the column's base directory by default) so that it can be moved to + # the final location if the object is successfully created. If the form is never completed, though, you + # can easily remove all the images in this "tmp" directory once per day or so. + # + # So in the example above, the image "test.png" would first be stored in + # "public/entry/image/tmp//test.png" and be moved to + # "public/entry/image//test.png". + # + # This temporary location of newly uploaded files has another advantage when updating objects. If the + # update fails for some reasons (e.g. due to validations), the existing image will not be overwritten, so + # it has a kind of "transactional behaviour". + # + # == Additional Files and Directories + # + # FileColumn allows you to keep more than one file in a directory and will move/delete + # all the files and directories it finds in a model object's directory when necessary. + # + # As a convenience you can access files stored in sub-directories via the +subdir+ + # parameter if they have the same filename. + # + # Suppose your uploaded file is named "vancouver.jpg" and you want to create a + # thumb-nail and store it in the "thumb" directory. If you call + # image("thumb"), you + # will receive an absolute path for the file "thumb/vancouver.jpg" in the same + # directory "vancouver.jpg" is stored. Look at the documentation of FileColumn::Magick + # for more examples and how to create these thumb-nails automatically. + # + # == File Extensions + # + # FileColumn will try to fix the file extension of uploaded files, so that + # the files are served with the correct mime-type by your web-server. Most + # web-servers are setting the mime-type based on the file's extension. You + # can disable this behaviour by passing the :fix_file_extensions option + # with a value of +nil+ to +file_column+. + # + # In order to set the correct extension, FileColumn tries to determine + # the files mime-type first. It then uses the +MIME_EXTENSIONS+ hash to + # choose the corresponding file extension. You can override this hash + # by passing in a :mime_extensions option to +file_column+. + # + # The mime-type of the uploaded file is determined with the following steps: + # + # 1. Run the external "file" utility. You can specify the full path to + # the executable in the :file_exec option or set this option + # to +nil+ to disable this step + # + # 2. If the file utility couldn't determine the mime-type or the utility was not + # present, the content-type provided by the user's browser is used + # as a fallback. + # + # == Custom Storage Directories + # + # FileColumn's storage location is determined in the following way. All + # files are saved below the so-called "root_path" directory, which defaults to + # "RAILS_ROOT/public". For every file_column, you can set a separte "store_dir" + # option. It defaults to "model_name/attribute_name". + # + # Files will always be stored in sub-directories of the store_dir path. The + # subdirectory is named after the instance's +id+ attribute for a saved model, + # or "tmp/" for unsaved models. + # + # You can specify a custom root_path by setting the :root_path option. + # + # You can specify a custom storage_dir by setting the :storage_dir option. + # + # For setting a static storage_dir that doesn't change with respect to a particular + # instance, you assign :storage_dir a String representing a directory + # as an absolute path. + # + # If you need more fine-grained control over the storage directory, you + # can use the name of a callback-method as a symbol for the + # :store_dir option. This method has to be defined as an + # instance method in your model. It will be called without any arguments + # whenever the storage directory for an uploaded file is needed. It should return + # a String representing a directory relativeo to root_path. + # + # Uploaded files for unsaved models objects will be stored in a temporary + # directory. By default this directory will be a "tmp" directory in + # your :store_dir. You can override this via the + # :tmp_base_dir option. + module ClassMethods + + # default mapping of mime-types to file extensions. FileColumn will try to + # rename a file to the correct extension if it detects a known mime-type + MIME_EXTENSIONS = { + "image/gif" => "gif", + "image/jpeg" => "jpg", + "image/pjpeg" => "jpg", + "image/x-png" => "png", + "image/jpg" => "jpg", + "image/png" => "png", + "application/x-shockwave-flash" => "swf", + "application/pdf" => "pdf", + "application/pgp-signature" => "sig", + "application/futuresplash" => "spl", + "application/msword" => "doc", + "application/postscript" => "ps", + "application/x-bittorrent" => "torrent", + "application/x-dvi" => "dvi", + "application/x-gzip" => "gz", + "application/x-ns-proxy-autoconfig" => "pac", + "application/x-shockwave-flash" => "swf", + "application/x-tgz" => "tar.gz", + "application/x-tar" => "tar", + "application/zip" => "zip", + "audio/mpeg" => "mp3", + "audio/x-mpegurl" => "m3u", + "audio/x-ms-wma" => "wma", + "audio/x-ms-wax" => "wax", + "audio/x-wav" => "wav", + "image/x-xbitmap" => "xbm", + "image/x-xpixmap" => "xpm", + "image/x-xwindowdump" => "xwd", + "text/css" => "css", + "text/html" => "html", + "text/javascript" => "js", + "text/plain" => "txt", + "text/xml" => "xml", + "video/mpeg" => "mpeg", + "video/quicktime" => "mov", + "video/x-msvideo" => "avi", + "video/x-ms-asf" => "asf", + "video/x-ms-wmv" => "wmv" + } + + EXTENSIONS = Set.new MIME_EXTENSIONS.values + EXTENSIONS.merge %w(jpeg) + + # default options. You can override these with +file_column+'s +options+ parameter + DEFAULT_OPTIONS = { + :root_path => File.join(RAILS_ROOT, "public"), + :web_root => "", + :mime_extensions => MIME_EXTENSIONS, + :extensions => EXTENSIONS, + :fix_file_extensions => true, + :permissions => 0644, + + # path to the unix "file" executbale for + # guessing the content-type of files + :file_exec => "file" + } + + # handle the +attr+ attribute as a "file-upload" column, generating additional methods as explained + # above. You should pass the attribute's name as a symbol, like this: + # + # file_column :image + # + # You can pass in an options hash that overrides the options + # in +DEFAULT_OPTIONS+. + def file_column(attr, options={}) + options = DEFAULT_OPTIONS.merge(options) if options + + my_options = FileColumn::init_options(options, + ActiveSupport::Inflector.underscore(self.name).to_s, + attr.to_s) + + state_attr = "@#{attr}_state".to_sym + state_method = "#{attr}_state".to_sym + + define_method state_method do + result = instance_variable_get state_attr + if result.nil? + result = FileColumn::create_state(self, attr.to_s) + instance_variable_set state_attr, result + end + result + end + + private state_method + + define_method attr do |*args| + send(state_method).absolute_path *args + end + + define_method "#{attr}_relative_path" do |*args| + send(state_method).relative_path *args + end + + define_method "#{attr}_dir" do + send(state_method).absolute_dir + end + + define_method "#{attr}_relative_dir" do + send(state_method).relative_dir + end + + define_method "#{attr}=" do |file| + state = send(state_method).assign(file) + instance_variable_set state_attr, state + if state.options[:after_upload] and state.just_uploaded? + state.options[:after_upload].each do |sym| + self.send sym + end + end + end + + define_method "#{attr}_temp" do + send(state_method).temp_path + end + + define_method "#{attr}_temp=" do |temp_path| + instance_variable_set state_attr, send(state_method).assign_temp(temp_path) + end + + after_save_method = "#{attr}_after_save".to_sym + + define_method after_save_method do + instance_variable_set state_attr, send(state_method).after_save + end + + after_save after_save_method + + after_destroy_method = "#{attr}_after_destroy".to_sym + + define_method after_destroy_method do + send(state_method).after_destroy + end + after_destroy after_destroy_method + + define_method "#{attr}_just_uploaded?" do + send(state_method).just_uploaded? + end + + # this creates a closure keeping a reference to my_options + # right now that's the only way we store the options. We + # might use a class attribute as well + define_method "#{attr}_options" do + my_options + end + + private after_save_method, after_destroy_method + + FileColumn::MagickExtension::file_column(self, attr, my_options) if options[:magick] + end + + end + + private + + def self.generate_temp_name + now = Time.now + "#{now.to_i}.#{now.usec}.#{Process.pid}" + end + + def self.sanitize_filename(filename) + filename = File.basename(filename.gsub("\\", "/")) # work-around for IE + filename.gsub!(/[^a-zA-Z0-9\.\-\+_]/,"_") + filename = "_#{filename}" if filename =~ /^\.+$/ + filename = "unnamed" if filename.size == 0 + filename + end + +end + + diff --git a/vendor/plugins/file_column/lib/file_column_helper.rb b/vendor/plugins/file_column/lib/file_column_helper.rb new file mode 100644 index 000000000..f4ebe38e7 --- /dev/null +++ b/vendor/plugins/file_column/lib/file_column_helper.rb @@ -0,0 +1,150 @@ +# This module contains helper methods for displaying and uploading files +# for attributes created by +FileColumn+'s +file_column+ method. It will be +# automatically included into ActionView::Base, thereby making this module's +# methods available in all your views. +module FileColumnHelper + + # Use this helper to create an upload field for a file_column attribute. This will generate + # an additional hidden field to keep uploaded files during form-redisplays. For example, + # when called with + # + # <%= file_column_field("entry", "image") %> + # + # the following HTML will be generated (assuming the form is redisplayed and something has + # already been uploaded): + # + # + # + # + # You can use the +option+ argument to pass additional options to the file-field tag. + # + # Be sure to set the enclosing form's encoding to 'multipart/form-data', by + # using something like this: + # + # <%= form_tag {:action => "create", ...}, :multipart => true %> + def file_column_field(object, method, options={}) + result = ActionView::Helpers::InstanceTag.new(object.dup, method.to_s+"_temp", self).to_input_field_tag("hidden", {}) + result << ActionView::Helpers::InstanceTag.new(object.dup, method, self).to_input_field_tag("file", options) + end + + # Creates an URL where an uploaded file can be accessed. When called for an Entry object with + # id 42 (stored in @entry) like this + # + # <%= url_for_file_column(@entry, "image") + # + # the following URL will be produced, assuming the file "test.png" has been stored in + # the "image"-column of an Entry object stored in @entry: + # + # /entry/image/42/test.png + # + # This will produce a valid URL even for temporary uploaded files, e.g. files where the object + # they are belonging to has not been saved in the database yet. + # + # The URL produces, although starting with a slash, will be relative + # to your app's root. If you pass it to one rails' +image_tag+ + # helper, rails will properly convert it to an absolute + # URL. However, this will not be the case, if you create a link with + # the +link_to+ helper. In this case, you can pass :absolute => + # true to +options+, which will make sure, the generated URL is + # absolute on your server. Examples: + # + # <%= image_tag url_for_file_column(@entry, "image") %> + # <%= link_to "Download", url_for_file_column(@entry, "image", :absolute => true) %> + # + # If there is currently no uploaded file stored in the object's column this method will + # return +nil+. + def url_for_file_column(object, method, options=nil) + case object + when String, Symbol + object = instance_variable_get("@#{object.to_s}") + end + + # parse options + subdir = nil + absolute = false + if options + case options + when Hash + subdir = options[:subdir] + absolute = options[:absolute] + when String, Symbol + subdir = options + end + end + + relative_path = object.send("#{method}_relative_path", subdir) + return nil unless relative_path + + url = "" + url << request.relative_url_root.to_s if absolute + url << "/" + url << object.send("#{method}_options")[:base_url] << "/" + url << relative_path + end + + # Same as +url_for_file_colum+ but allows you to access different versions + # of the image that have been processed by RMagick. + # + # If your +options+ parameter is non-nil this will + # access a different version of an image that will be produced by + # RMagick. You can use the following types for +options+: + # + # * a :symbol will select a version defined in the model + # via FileColumn::Magick's :versions feature. + # * a geometry_string will dynamically create an + # image resized as specified by geometry_string. The image will + # be stored so that it does not have to be recomputed the next time the + # same version string is used. + # * some_hash will dynamically create an image + # that is created according to the options in some_hash. This + # accepts exactly the same options as Magick's version feature. + # + # The version produced by RMagick will be stored in a special sub-directory. + # The directory's name will be derived from the options you specified + # (via a hash function) but if you want + # to set it yourself, you can use the :name => name option. + # + # Examples: + # + # <%= url_for_image_column @entry, "image", "640x480" %> + # + # will produce an URL like this + # + # /entry/image/42/bdn19n/filename.jpg + # # "640x480".hash.abs.to_s(36) == "bdn19n" + # + # and + # + # <%= url_for_image_column @entry, "image", + # :size => "50x50", :crop => "1:1", :name => "thumb" %> + # + # will produce something like this: + # + # /entry/image/42/thumb/filename.jpg + # + # Hint: If you are using the same geometry string / options hash multiple times, you should + # define it in a helper to stay with DRY. Another option is to define it in the model via + # FileColumn::Magick's :versions feature and then refer to it via a symbol. + # + # The URL produced by this method is relative to your application's root URL, + # although it will start with a slash. + # If you pass this URL to rails' +image_tag+ helper, it will be converted to an + # absolute URL automatically. + # If there is currently no image uploaded, or there is a problem while loading + # the image this method will return +nil+. + def url_for_image_column(object, method, options=nil) + case object + when String, Symbol + object = instance_variable_get("@#{object.to_s}") + end + subdir = nil + if options + subdir = object.send("#{method}_state").create_magick_version_if_needed(options) + end + if subdir.nil? + nil + else + url_for_file_column(object, method, subdir) + end + end +end diff --git a/vendor/plugins/file_column/lib/file_compat.rb b/vendor/plugins/file_column/lib/file_compat.rb new file mode 100644 index 000000000..f284410a3 --- /dev/null +++ b/vendor/plugins/file_column/lib/file_compat.rb @@ -0,0 +1,28 @@ +module FileColumn + + # This bit of code allows you to pass regular old files to + # file_column. file_column depends on a few extra methods that the + # CGI uploaded file class adds. We will add the equivalent methods + # to file objects if necessary by extending them with this module. This + # avoids opening up the standard File class which might result in + # naming conflicts. + + module FileCompat # :nodoc: + def original_filename + File.basename(path) + end + + def size + File.size(path) + end + + def local_path + path + end + + def content_type + nil + end + end +end + diff --git a/vendor/plugins/file_column/lib/magick_file_column.rb b/vendor/plugins/file_column/lib/magick_file_column.rb new file mode 100644 index 000000000..c4dc06fc3 --- /dev/null +++ b/vendor/plugins/file_column/lib/magick_file_column.rb @@ -0,0 +1,260 @@ +module FileColumn # :nodoc: + + class BaseUploadedFile # :nodoc: + def transform_with_magick + if needs_transform? + begin + img = ::Magick::Image::read(absolute_path).first + rescue ::Magick::ImageMagickError + if options[:magick][:image_required] + @magick_errors ||= [] + @magick_errors << "invalid image" + end + return + end + + if options[:magick][:versions] + options[:magick][:versions].each_pair do |version, version_options| + next if version_options[:lazy] + dirname = version_options[:name] + FileUtils.mkdir File.join(@dir, dirname) + transform_image(img, version_options, absolute_path(dirname)) + end + end + if options[:magick][:size] or options[:magick][:crop] or options[:magick][:transformation] or options[:magick][:attributes] + transform_image(img, options[:magick], absolute_path) + end + + GC.start + end + end + + def create_magick_version_if_needed(version) + # RMagick might not have been loaded so far. + # We do not want to require it on every call of this method + # as this might be fairly expensive, so we just try if ::Magick + # exists and require it if not. + begin + ::Magick + rescue NameError + require 'RMagick' + end + + if version.is_a?(Symbol) + version_options = options[:magick][:versions][version] + else + version_options = MagickExtension::process_options(version) + end + + unless File.exists?(absolute_path(version_options[:name])) + begin + img = ::Magick::Image::read(absolute_path).first + rescue ::Magick::ImageMagickError + # we might be called directly from the view here + # so we just return nil if we cannot load the image + return nil + end + dirname = version_options[:name] + FileUtils.mkdir File.join(@dir, dirname) + transform_image(img, version_options, absolute_path(dirname)) + end + + version_options[:name] + end + + attr_reader :magick_errors + + def has_magick_errors? + @magick_errors and !@magick_errors.empty? + end + + private + + def needs_transform? + options[:magick] and just_uploaded? and + (options[:magick][:size] or options[:magick][:versions] or options[:magick][:transformation] or options[:magick][:attributes]) + end + + def transform_image(img, img_options, dest_path) + begin + if img_options[:transformation] + if img_options[:transformation].is_a?(Symbol) + img = @instance.send(img_options[:transformation], img) + else + img = img_options[:transformation].call(img) + end + end + if img_options[:crop] + dx, dy = img_options[:crop].split(':').map { |x| x.to_f } + w, h = (img.rows * dx / dy), (img.columns * dy / dx) + img = img.crop(::Magick::CenterGravity, [img.columns, w].min, + [img.rows, h].min, true) + end + + if img_options[:size] + img = img.change_geometry(img_options[:size]) do |c, r, i| + i.resize(c, r) + end + end + ensure + img.write(dest_path) do + if img_options[:attributes] + img_options[:attributes].each_pair do |property, value| + self.send "#{property}=", value + end + end + end + File.chmod options[:permissions], dest_path + end + end + end + + # If you are using file_column to upload images, you can + # directly process the images with RMagick, + # a ruby extension + # for accessing the popular imagemagick libraries. You can find + # more information about RMagick at http://rmagick.rubyforge.org. + # + # You can control what to do by adding a :magick option + # to your options hash. All operations are performed immediately + # after a new file is assigned to the file_column attribute (i.e., + # when a new file has been uploaded). + # + # == Resizing images + # + # To resize the uploaded image according to an imagemagick geometry + # string, just use the :size option: + # + # file_column :image, :magick => {:size => "800x600>"} + # + # If the uploaded file cannot be loaded by RMagick, file_column will + # signal a validation error for the corresponding attribute. If you + # want to allow non-image files to be uploaded in a column that uses + # the :magick option, you can set the :image_required + # attribute to +false+: + # + # file_column :image, :magick => {:size => "800x600>", + # :image_required => false } + # + # == Multiple versions + # + # You can also create additional versions of your image, for example + # thumb-nails, like this: + # file_column :image, :magick => {:versions => { + # :thumb => {:size => "50x50"}, + # :medium => {:size => "640x480>"} + # } + # + # These versions will be stored in separate sub-directories, named like the + # symbol you used to identify the version. So in the previous example, the + # image versions will be stored in "thumb", "screen" and "widescreen" + # directories, resp. + # A name different from the symbol can be set via the :name option. + # + # These versions can be accessed via FileColumnHelper's +url_for_image_column+ + # method like this: + # + # <%= url_for_image_column "entry", "image", :thumb %> + # + # == Cropping images + # + # If you wish to crop your images with a size ratio before scaling + # them according to your version geometry, you can use the :crop directive. + # file_column :image, :magick => {:versions => { + # :square => {:crop => "1:1", :size => "50x50", :name => "thumb"}, + # :screen => {:crop => "4:3", :size => "640x480>"}, + # :widescreen => {:crop => "16:9", :size => "640x360!"}, + # } + # } + # + # == Custom attributes + # + # To change some of the image properties like compression level before they + # are saved you can set the :attributes option. + # For a list of available attributes go to http://www.simplesystems.org/RMagick/doc/info.html + # + # file_column :image, :magick => { :attributes => { :quality => 30 } } + # + # == Custom transformations + # + # To perform custom transformations on uploaded images, you can pass a + # callback to file_column: + # file_column :image, :magick => + # Proc.new { |image| image.quantize(256, Magick::GRAYColorspace) } + # + # The callback you give, receives one argument, which is an instance + # of Magick::Image, the RMagick image class. It should return a transformed + # image. Instead of passing a Proc object, you can also give a + # Symbol, the name of an instance method of your model. + # + # Custom transformations can be combined via the standard :size and :crop + # features, by using the :transformation option: + # file_column :image, :magick => { + # :transformation => Proc.new { |image| ... }, + # :size => "640x480" + # } + # + # In this case, the standard resizing operations will be performed after the + # custom transformation. + # + # Of course, custom transformations can be used in versions, as well. + # + # Note: You'll need the + # RMagick extension being installed in order to use file_column's + # imagemagick integration. + module MagickExtension + + def self.file_column(klass, attr, options) # :nodoc: + require 'RMagick' + options[:magick] = process_options(options[:magick],false) if options[:magick] + if options[:magick][:versions] + options[:magick][:versions].each_pair do |name, value| + options[:magick][:versions][name] = process_options(value, name.to_s) + end + end + state_method = "#{attr}_state".to_sym + after_assign_method = "#{attr}_magick_after_assign".to_sym + + klass.send(:define_method, after_assign_method) do + self.send(state_method).transform_with_magick + end + + options[:after_upload] ||= [] + options[:after_upload] << after_assign_method + + klass.validate do |record| + state = record.send(state_method) + if state.has_magick_errors? + state.magick_errors.each do |error| + record.errors.add attr, error + end + end + end + end + + + def self.process_options(options,create_name=true) + case options + when String then options = {:size => options} + when Proc, Symbol then options = {:transformation => options } + end + if options[:geometry] + options[:size] = options.delete(:geometry) + end + options[:image_required] = true unless options.key?(:image_required) + if options[:name].nil? and create_name + if create_name == true + hash = 0 + for key in [:size, :crop] + hash = hash ^ options[key].hash if options[key] + end + options[:name] = hash.abs.to_s(36) + else + options[:name] = create_name + end + end + options + end + + end +end diff --git a/vendor/plugins/file_column/lib/rails_file_column.rb b/vendor/plugins/file_column/lib/rails_file_column.rb new file mode 100644 index 000000000..af8c95a84 --- /dev/null +++ b/vendor/plugins/file_column/lib/rails_file_column.rb @@ -0,0 +1,19 @@ +# require this file from your "config/environment.rb" (after rails has been loaded) +# to integrate the file_column extension into rails. + +require 'file_column' +require 'file_column_helper' + + +module ActiveRecord # :nodoc: + class Base # :nodoc: + # make file_column method available in all active record decendants + include FileColumn + end +end + +module ActionView # :nodoc: + class Base # :nodoc: + include FileColumnHelper + end +end diff --git a/vendor/plugins/file_column/lib/test_case.rb b/vendor/plugins/file_column/lib/test_case.rb new file mode 100644 index 000000000..1416a1e7f --- /dev/null +++ b/vendor/plugins/file_column/lib/test_case.rb @@ -0,0 +1,124 @@ +require 'test/unit' + +# Add the methods +upload+, the setup_file_fixtures and +# teardown_file_fixtures to the class Test::Unit::TestCase. +class Test::Unit::TestCase + # Returns a +Tempfile+ object as it would have been generated on file upload. + # Use this method to create the parameters when emulating form posts with + # file fields. + # + # === Example: + # + # def test_file_column_post + # entry = { :title => 'foo', :file => upload('/tmp/foo.txt')} + # post :upload, :entry => entry + # + # # ... + # end + # + # === Parameters + # + # * path The path to the file to upload. + # * content_type The MIME type of the file. If it is :guess, + # the method will try to guess it. + def upload(path, content_type=:guess, type=:tempfile) + if content_type == :guess + case path + when /\.jpg$/ then content_type = "image/jpeg" + when /\.png$/ then content_type = "image/png" + else content_type = nil + end + end + uploaded_file(path, content_type, File.basename(path), type) + end + + # Copies the fixture files from "RAILS_ROOT/test/fixtures/file_column" into + # the temporary storage directory used for testing + # ("RAILS_ROOT/test/tmp/file_column"). Call this method in your + # setup methods to get the file fixtures (images, for example) into + # the directory used by file_column in testing. + # + # Note that the files and directories in the "fixtures/file_column" directory + # must have the same structure as you would expect in your "/public" directory + # after uploading with FileColumn. + # + # For example, the directory structure could look like this: + # + # test/fixtures/file_column/ + # `-- container + # |-- first_image + # | |-- 1 + # | | `-- image1.jpg + # | `-- tmp + # `-- second_image + # |-- 1 + # | `-- image2.jpg + # `-- tmp + # + # Your fixture file for this one "container" class fixture could look like this: + # + # first: + # id: 1 + # first_image: image1.jpg + # second_image: image1.jpg + # + # A usage example: + # + # def setup + # setup_fixture_files + # + # # ... + # end + def setup_fixture_files + tmp_path = File.join(RAILS_ROOT, "test", "tmp", "file_column") + file_fixtures = Dir.glob File.join(RAILS_ROOT, "test", "fixtures", "file_column", "*") + + FileUtils.mkdir_p tmp_path unless File.exists?(tmp_path) + FileUtils.cp_r file_fixtures, tmp_path + end + + # Removes the directory "RAILS_ROOT/test/tmp/file_column/" so the files + # copied on test startup are removed. Call this in your unit test's +teardown+ + # method. + # + # A usage example: + # + # def teardown + # teardown_fixture_files + # + # # ... + # end + def teardown_fixture_files + FileUtils.rm_rf File.join(RAILS_ROOT, "test", "tmp", "file_column") + end + + private + + def uploaded_file(path, content_type, filename, type=:tempfile) # :nodoc: + if type == :tempfile + t = Tempfile.new(File.basename(filename)) + FileUtils.copy_file(path, t.path) + else + if path + t = StringIO.new(IO.read(path)) + else + t = StringIO.new + end + end + (class << t; self; end).class_eval do + alias local_path path if type == :tempfile + define_method(:local_path) { "" } if type == :stringio + define_method(:original_filename) {filename} + define_method(:content_type) {content_type} + end + return t + end +end + +# If we are running in the "test" environment, we overwrite the default +# settings for FileColumn so that files are not uploaded into "/public/" +# in tests but rather into the directory "/test/tmp/file_column". +if RAILS_ENV == "test" + FileColumn::ClassMethods::DEFAULT_OPTIONS[:root_path] = + File.join(RAILS_ROOT, "test", "tmp", "file_column") +end diff --git a/vendor/plugins/file_column/lib/validations.rb b/vendor/plugins/file_column/lib/validations.rb new file mode 100644 index 000000000..5b961eb9c --- /dev/null +++ b/vendor/plugins/file_column/lib/validations.rb @@ -0,0 +1,112 @@ +module FileColumn + module Validations #:nodoc: + + def self.append_features(base) + super + base.extend(ClassMethods) + end + + # This module contains methods to create validations of uploaded files. All methods + # in this module will be included as class methods into ActiveRecord::Base + # so that you can use them in your models like this: + # + # class Entry < ActiveRecord::Base + # file_column :image + # validates_filesize_of :image, :in => 0..1.megabyte + # end + module ClassMethods + EXT_REGEXP = /\.([A-z0-9]+)$/ + + # This validates the file type of one or more file_columns. A list of file columns + # should be given followed by an options hash. + # + # Required options: + # * :in => list of extensions or mime types. If mime types are used they + # will be mapped into an extension via FileColumn::ClassMethods::MIME_EXTENSIONS. + # + # Examples: + # validates_file_format_of :field, :in => ["gif", "png", "jpg"] + # validates_file_format_of :field, :in => ["image/jpeg"] + def validates_file_format_of(*attrs) + + options = attrs.pop if attrs.last.is_a?Hash + raise ArgumentError, "Please include the :in option." if !options || !options[:in] + options[:in] = [options[:in]] if options[:in].is_a?String + raise ArgumentError, "Invalid value for option :in" unless options[:in].is_a?Array + + validates_each(attrs, options) do |record, attr, value| + unless value.blank? + mime_extensions = record.send("#{attr}_options")[:mime_extensions] + extensions = options[:in].map{|o| mime_extensions[o] || o } + record.errors.add attr, "is not a valid format." unless extensions.include?(value.scan(EXT_REGEXP).flatten.first) + end + end + + end + + # This validates the file size of one or more file_columns. A list of file columns + # should be given followed by an options hash. + # + # Required options: + # * :in => A size range. Note that you can use ActiveSupport's + # numeric extensions for kilobytes, etc. + # + # Examples: + # validates_filesize_of :field, :in => 0..100.megabytes + # validates_filesize_of :field, :in => 15.kilobytes..1.megabyte + def validates_filesize_of(*attrs) + + options = attrs.pop if attrs.last.is_a?Hash + raise ArgumentError, "Please include the :in option." if !options || !options[:in] + raise ArgumentError, "Invalid value for option :in" unless options[:in].is_a?Range + + validates_each(attrs, options) do |record, attr, value| + unless value.blank? + size = File.size(value) + record.errors.add attr, "is smaller than the allowed size range." if size < options[:in].first + record.errors.add attr, "is larger than the allowed size range." if size > options[:in].last + end + end + + end + + IMAGE_SIZE_REGEXP = /^(\d+)x(\d+)$/ + + # Validates the image size of one or more file_columns. A list of file columns + # should be given followed by an options hash. The validation will pass + # if both image dimensions (rows and columns) are at least as big as + # given in the :min option. + # + # Required options: + # * :min => minimum image dimension string, in the format NNxNN + # (columns x rows). + # + # Example: + # validates_image_size :field, :min => "1200x1800" + # + # This validation requires RMagick to be installed on your system + # to check the image's size. + def validates_image_size(*attrs) + options = attrs.pop if attrs.last.is_a?Hash + raise ArgumentError, "Please include a :min option." if !options || !options[:min] + minimums = options[:min].scan(IMAGE_SIZE_REGEXP).first.collect{|n| n.to_i} rescue [] + raise ArgumentError, "Invalid value for option :min (should be 'XXxYY')" unless minimums.size == 2 + + require 'RMagick' + + validates_each(attrs, options) do |record, attr, value| + unless value.blank? + begin + img = ::Magick::Image::read(value).first + record.errors.add('image', "is too small, must be at least #{minimums[0]}x#{minimums[1]}") if ( img.rows < minimums[1] || img.columns < minimums[0] ) + rescue ::Magick::ImageMagickError + record.errors.add('image', "invalid image") + end + img = nil + GC.start + end + end + end + end + end +end diff --git a/vendor/plugins/file_column/test/abstract_unit.rb b/vendor/plugins/file_column/test/abstract_unit.rb new file mode 100644 index 000000000..22bc53b70 --- /dev/null +++ b/vendor/plugins/file_column/test/abstract_unit.rb @@ -0,0 +1,63 @@ +require 'test/unit' +require 'rubygems' +require 'active_support' +require 'active_record' +require 'action_view' +require File.dirname(__FILE__) + '/connection' +require 'stringio' + +RAILS_ROOT = File.dirname(__FILE__) +RAILS_ENV = "" + +$: << "../lib" + +require 'file_column' +require 'file_compat' +require 'validations' +require 'test_case' + +# do not use the file executable normally in our tests as +# it may not be present on the machine we are running on +FileColumn::ClassMethods::DEFAULT_OPTIONS = + FileColumn::ClassMethods::DEFAULT_OPTIONS.merge({:file_exec => nil}) + +class ActiveRecord::Base + include FileColumn + include FileColumn::Validations +end + + +class RequestMock + attr_accessor :relative_url_root + + def initialize + @relative_url_root = "" + end +end + +class Test::Unit::TestCase + + def assert_equal_paths(expected_path, path) + assert_equal normalize_path(expected_path), normalize_path(path) + end + + + private + + def normalize_path(path) + Pathname.new(path).realpath + end + + def clear_validations + [:validate, :validate_on_create, :validate_on_update].each do |attr| + Entry.write_inheritable_attribute attr, [] + Movie.write_inheritable_attribute attr, [] + end + end + + def file_path(filename) + File.expand_path("#{File.dirname(__FILE__)}/fixtures/#{filename}") + end + + alias_method :f, :file_path +end diff --git a/vendor/plugins/file_column/test/connection.rb b/vendor/plugins/file_column/test/connection.rb new file mode 100644 index 000000000..a2f28baca --- /dev/null +++ b/vendor/plugins/file_column/test/connection.rb @@ -0,0 +1,17 @@ +print "Using native MySQL\n" +require 'logger' + +ActiveRecord::Base.logger = Logger.new("debug.log") + +db = 'file_column_test' + +ActiveRecord::Base.establish_connection( + :adapter => "mysql", + :host => "localhost", + :username => "rails", + :password => "", + :database => db, + :socket => "/var/run/mysqld/mysqld.sock" +) + +load File.dirname(__FILE__) + "/fixtures/schema.rb" diff --git a/vendor/plugins/file_column/test/file_column_helper_test.rb b/vendor/plugins/file_column/test/file_column_helper_test.rb new file mode 100644 index 000000000..ffb2c43b8 --- /dev/null +++ b/vendor/plugins/file_column/test/file_column_helper_test.rb @@ -0,0 +1,97 @@ +require File.dirname(__FILE__) + '/abstract_unit' +require File.dirname(__FILE__) + '/fixtures/entry' + +class UrlForFileColumnTest < Test::Unit::TestCase + include FileColumnHelper + + def setup + Entry.file_column :image + @request = RequestMock.new + end + + def test_url_for_file_column_with_temp_entry + @e = Entry.new(:image => upload(f("skanthak.png"))) + url = url_for_file_column("e", "image") + assert_match %r{^/entry/image/tmp/\d+(\.\d+)+/skanthak.png$}, url + end + + def test_url_for_file_column_with_saved_entry + @e = Entry.new(:image => upload(f("skanthak.png"))) + assert @e.save + + url = url_for_file_column("e", "image") + assert_equal "/entry/image/#{@e.id}/skanthak.png", url + end + + def test_url_for_file_column_works_with_symbol + @e = Entry.new(:image => upload(f("skanthak.png"))) + assert @e.save + + url = url_for_file_column(:e, :image) + assert_equal "/entry/image/#{@e.id}/skanthak.png", url + end + + def test_url_for_file_column_works_with_object + e = Entry.new(:image => upload(f("skanthak.png"))) + assert e.save + + url = url_for_file_column(e, "image") + assert_equal "/entry/image/#{e.id}/skanthak.png", url + end + + def test_url_for_file_column_should_return_nil_on_no_uploaded_file + e = Entry.new + assert_nil url_for_file_column(e, "image") + end + + def test_url_for_file_column_without_extension + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "something/unknown", "local_filename") + assert e.save + assert_equal "/entry/image/#{e.id}/local_filename", url_for_file_column(e, "image") + end +end + +class UrlForFileColumnTest < Test::Unit::TestCase + include FileColumnHelper + include ActionView::Helpers::AssetTagHelper + include ActionView::Helpers::TagHelper + include ActionView::Helpers::UrlHelper + + def setup + Entry.file_column :image + + # mock up some request data structures for AssetTagHelper + @request = RequestMock.new + @request.relative_url_root = "/foo/bar" + @controller = self + end + + def request + @request + end + + IMAGE_URL = %r{^/foo/bar/entry/image/.+/skanthak.png$} + def test_with_image_tag + e = Entry.new(:image => upload(f("skanthak.png"))) + html = image_tag url_for_file_column(e, "image") + url = html.scan(/src=\"(.+)\"/).first.first + + assert_match IMAGE_URL, url + end + + def test_with_link_to_tag + e = Entry.new(:image => upload(f("skanthak.png"))) + html = link_to "Download", url_for_file_column(e, "image", :absolute => true) + url = html.scan(/href=\"(.+)\"/).first.first + + assert_match IMAGE_URL, url + end + + def test_relative_url_root_not_modified + e = Entry.new(:image => upload(f("skanthak.png"))) + url_for_file_column(e, "image", :absolute => true) + + assert_equal "/foo/bar", @request.relative_url_root + end +end diff --git a/vendor/plugins/file_column/test/file_column_test.rb b/vendor/plugins/file_column/test/file_column_test.rb new file mode 100755 index 000000000..452b7815d --- /dev/null +++ b/vendor/plugins/file_column/test/file_column_test.rb @@ -0,0 +1,650 @@ +require File.dirname(__FILE__) + '/abstract_unit' + +require File.dirname(__FILE__) + '/fixtures/entry' + +class Movie < ActiveRecord::Base +end + + +class FileColumnTest < Test::Unit::TestCase + + def setup + # we define the file_columns here so that we can change + # settings easily in a single test + + Entry.file_column :image + Entry.file_column :file + Movie.file_column :movie + + clear_validations + end + + def teardown + FileUtils.rm_rf File.dirname(__FILE__)+"/public/entry/" + FileUtils.rm_rf File.dirname(__FILE__)+"/public/movie/" + FileUtils.rm_rf File.dirname(__FILE__)+"/public/my_store_dir/" + end + + def test_column_write_method + assert Entry.new.respond_to?("image=") + end + + def test_column_read_method + assert Entry.new.respond_to?("image") + end + + def test_sanitize_filename + assert_equal "test.jpg", FileColumn::sanitize_filename("test.jpg") + assert FileColumn::sanitize_filename("../../very_tricky/foo.bar") !~ /[\\\/]/, "slashes not removed" + assert_equal "__foo", FileColumn::sanitize_filename('`*foo') + assert_equal "foo.txt", FileColumn::sanitize_filename('c:\temp\foo.txt') + assert_equal "_.", FileColumn::sanitize_filename(".") + end + + def test_default_options + e = Entry.new + assert_match %r{/public/entry/image}, e.image_options[:store_dir] + assert_match %r{/public/entry/image/tmp}, e.image_options[:tmp_base_dir] + end + + def test_assign_without_save_with_tempfile + do_test_assign_without_save(:tempfile) + end + + def test_assign_without_save_with_stringio + do_test_assign_without_save(:stringio) + end + + def do_test_assign_without_save(upload_type) + e = Entry.new + e.image = uploaded_file(file_path("skanthak.png"), "image/png", "skanthak.png", upload_type) + assert e.image.is_a?(String), "#{e.image.inspect} is not a String" + assert File.exists?(e.image) + assert FileUtils.identical?(e.image, file_path("skanthak.png")) + end + + def test_filename_preserved + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "image/jpeg", "local_filename.jpg") + assert_equal "local_filename.jpg", File.basename(e.image) + end + + def test_filename_stored_in_attribute + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg")) + assert_equal "kerb.jpg", e["image"] + end + + def test_extension_added + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "image/jpeg", "local_filename") + assert_equal "local_filename.jpg", File.basename(e.image) + assert_equal "local_filename.jpg", e["image"] + end + + def test_no_extension_without_content_type + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "something/unknown", "local_filename") + assert_equal "local_filename", File.basename(e.image) + assert_equal "local_filename", e["image"] + end + + def test_extension_unknown_type + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "not/known", "local_filename") + assert_equal "local_filename", File.basename(e.image) + assert_equal "local_filename", e["image"] + end + + def test_extension_unknown_type_with_extension + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "not/known", "local_filename.abc") + assert_equal "local_filename.abc", File.basename(e.image) + assert_equal "local_filename.abc", e["image"] + end + + def test_extension_corrected + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "image/jpeg", "local_filename.jpeg") + assert_equal "local_filename.jpg", File.basename(e.image) + assert_equal "local_filename.jpg", e["image"] + end + + def test_double_extension + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "application/x-tgz", "local_filename.tar.gz") + assert_equal "local_filename.tar.gz", File.basename(e.image) + assert_equal "local_filename.tar.gz", e["image"] + end + + FILE_UTILITY = "/usr/bin/file" + + def test_get_content_type_with_file + Entry.file_column :image, :file_exec => FILE_UTILITY + + # run this test only if the machine we are running on + # has the file utility installed + if File.executable?(FILE_UTILITY) + e = Entry.new + file = FileColumn::TempUploadedFile.new(e, "image") + file.instance_variable_set :@dir, File.dirname(file_path("kerb.jpg")) + file.instance_variable_set :@filename, File.basename(file_path("kerb.jpg")) + + assert_equal "image/jpeg", file.get_content_type + else + puts "Warning: Skipping test_get_content_type_with_file test as '#{options[:file_exec]}' does not exist" + end + end + + def test_fix_extension_with_file + Entry.file_column :image, :file_exec => FILE_UTILITY + + # run this test only if the machine we are running on + # has the file utility installed + if File.executable?(FILE_UTILITY) + e = Entry.new(:image => uploaded_file(file_path("skanthak.png"), "", "skanthak.jpg")) + + assert_equal "skanthak.png", File.basename(e.image) + else + puts "Warning: Skipping test_fix_extension_with_file test as '#{options[:file_exec]}' does not exist" + end + end + + def test_do_not_fix_file_extensions + Entry.file_column :image, :fix_file_extensions => false + + e = Entry.new(:image => uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb")) + + assert_equal "kerb", File.basename(e.image) + end + + def test_correct_extension + e = Entry.new + file = FileColumn::TempUploadedFile.new(e, "image") + + assert_equal "filename.jpg", file.correct_extension("filename.jpeg","jpg") + assert_equal "filename.tar.gz", file.correct_extension("filename.jpg","tar.gz") + assert_equal "filename.jpg", file.correct_extension("filename.tar.gz","jpg") + assert_equal "Protokoll_01.09.2005.doc", file.correct_extension("Protokoll_01.09.2005","doc") + assert_equal "strange.filenames.exist.jpg", file.correct_extension("strange.filenames.exist","jpg") + assert_equal "another.strange.one.jpg", file.correct_extension("another.strange.one.png","jpg") + end + + def test_assign_with_save + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg") + tmp_file_path = e.image + assert e.save + assert File.exists?(e.image) + assert FileUtils.identical?(e.image, file_path("kerb.jpg")) + assert_equal "#{e.id}/kerb.jpg", e.image_relative_path + assert !File.exists?(tmp_file_path), "temporary file '#{tmp_file_path}' not removed" + assert !File.exists?(File.dirname(tmp_file_path)), "temporary directory '#{File.dirname(tmp_file_path)}' not removed" + + local_path = e.image + e = Entry.find(e.id) + assert_equal local_path, e.image + end + + def test_dir_methods + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg") + e.save + + assert_equal_paths File.join(RAILS_ROOT, "public", "entry", "image", e.id.to_s), e.image_dir + assert_equal File.join(e.id.to_s), e.image_relative_dir + end + + def test_store_dir_callback + Entry.file_column :image, {:store_dir => :my_store_dir} + e = Entry.new + + e.image = uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg") + assert e.save + + assert_equal_paths File.join(RAILS_ROOT, "public", "my_store_dir", e.id), e.image_dir + end + + def test_tmp_dir_with_store_dir_callback + Entry.file_column :image, {:store_dir => :my_store_dir} + e = Entry.new + e.image = upload(f("kerb.jpg")) + + assert_equal File.expand_path(File.join(RAILS_ROOT, "public", "my_store_dir", "tmp")), File.expand_path(File.join(e.image_dir,"..")) + end + + def test_invalid_store_dir_callback + Entry.file_column :image, {:store_dir => :my_store_dir_doesnt_exit} + e = Entry.new + assert_raise(ArgumentError) { + e.image = uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg") + e.save + } + end + + def test_subdir_parameter + e = Entry.new + assert_nil e.image("thumb") + assert_nil e.image_relative_path("thumb") + assert_nil e.image(nil) + + e.image = uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg") + + assert_equal "kerb.jpg", File.basename(e.image("thumb")) + assert_equal "kerb.jpg", File.basename(e.image_relative_path("thumb")) + + assert_equal File.join(e.image_dir,"thumb","kerb.jpg"), e.image("thumb") + assert_match %r{/thumb/kerb\.jpg$}, e.image_relative_path("thumb") + + assert_equal e.image, e.image(nil) + assert_equal e.image_relative_path, e.image_relative_path(nil) + end + + def test_cleanup_after_destroy + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg")) + assert e.save + local_path = e.image + assert File.exists?(local_path) + assert e.destroy + assert !File.exists?(local_path), "'#{local_path}' still exists although entry was destroyed" + assert !File.exists?(File.dirname(local_path)) + end + + def test_keep_tmp_image + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg")) + e.validation_should_fail = true + assert !e.save, "e should not save due to validation errors" + assert File.exists?(local_path = e.image) + image_temp = e.image_temp + e = Entry.new("image_temp" => image_temp) + assert_equal local_path, e.image + assert e.save + assert FileUtils.identical?(e.image, file_path("kerb.jpg")) + end + + def test_keep_tmp_image_with_existing_image + e = Entry.new("image" =>uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg")) + assert e.save + assert File.exists?(local_path = e.image) + e = Entry.find(e.id) + e.image = uploaded_file(file_path("skanthak.png"), "image/png", "skanthak.png") + e.validation_should_fail = true + assert !e.save + temp_path = e.image_temp + e = Entry.find(e.id) + e.image_temp = temp_path + assert e.save + + assert FileUtils.identical?(e.image, file_path("skanthak.png")) + assert !File.exists?(local_path), "old image has not been deleted" + end + + def test_replace_tmp_image_temp_first + do_test_replace_tmp_image([:image_temp, :image]) + end + + def test_replace_tmp_image_temp_last + do_test_replace_tmp_image([:image, :image_temp]) + end + + def do_test_replace_tmp_image(order) + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg")) + e.validation_should_fail = true + assert !e.save + image_temp = e.image_temp + temp_path = e.image + new_img = uploaded_file(file_path("skanthak.png"), "image/png", "skanthak.png") + e = Entry.new + for method in order + case method + when :image_temp then e.image_temp = image_temp + when :image then e.image = new_img + end + end + assert e.save + assert FileUtils.identical?(e.image, file_path("skanthak.png")), "'#{e.image}' is not the expected 'skanthak.png'" + assert !File.exists?(temp_path), "temporary file '#{temp_path}' is not cleaned up" + assert !File.exists?(File.dirname(temp_path)), "temporary directory not cleaned up" + assert e.image_just_uploaded? + end + + def test_replace_image_on_saved_object + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg")) + assert e.save + old_file = e.image + e = Entry.find(e.id) + e.image = uploaded_file(file_path("skanthak.png"), "image/png", "skanthak.png") + assert e.save + assert FileUtils.identical?(file_path("skanthak.png"), e.image) + assert old_file != e.image + assert !File.exists?(old_file), "'#{old_file}' has not been cleaned up" + end + + def test_edit_without_touching_image + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg")) + assert e.save + e = Entry.find(e.id) + assert e.save + assert FileUtils.identical?(file_path("kerb.jpg"), e.image) + end + + def test_save_without_image + e = Entry.new + assert e.save + e.reload + assert_nil e.image + end + + def test_delete_saved_image + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg")) + assert e.save + local_path = e.image + e.image = nil + assert_nil e.image + assert File.exists?(local_path), "file '#{local_path}' should not be deleted until transaction is saved" + assert e.save + assert_nil e.image + assert !File.exists?(local_path) + e.reload + assert e["image"].blank? + e = Entry.find(e.id) + assert_nil e.image + end + + def test_delete_tmp_image + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg")) + local_path = e.image + e.image = nil + assert_nil e.image + assert e["image"].blank? + assert !File.exists?(local_path) + end + + def test_delete_nonexistant_image + e = Entry.new + e.image = nil + assert e.save + assert_nil e.image + end + + def test_delete_image_on_non_null_column + e = Entry.new("file" => upload(f("skanthak.png"))) + assert e.save + + local_path = e.file + assert File.exists?(local_path) + e.file = nil + assert e.save + assert !File.exists?(local_path) + end + + def test_ie_filename + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", 'c:\images\kerb.jpg')) + assert e.image_relative_path =~ /^tmp\/[\d\.]+\/kerb\.jpg$/, "relative path '#{e.image_relative_path}' was not as expected" + assert File.exists?(e.image) + end + + def test_just_uploaded? + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", 'c:\images\kerb.jpg')) + assert e.image_just_uploaded? + assert e.save + assert e.image_just_uploaded? + + e = Entry.new("image" => uploaded_file(file_path("kerb.jpg"), "image/jpeg", 'kerb.jpg')) + temp_path = e.image_temp + e = Entry.new("image_temp" => temp_path) + assert !e.image_just_uploaded? + assert e.save + assert !e.image_just_uploaded? + end + + def test_empty_tmp + e = Entry.new + e.image_temp = "" + assert_nil e.image + end + + def test_empty_tmp_with_image + e = Entry.new + e.image_temp = "" + e.image = uploaded_file(file_path("kerb.jpg"), "image/jpeg", 'c:\images\kerb.jpg') + local_path = e.image + assert File.exists?(local_path) + e.image_temp = "" + assert local_path, e.image + end + + def test_empty_filename + e = Entry.new + assert_equal "", e["file"] + assert_nil e.file + assert_nil e["image"] + assert_nil e.image + end + + def test_with_two_file_columns + e = Entry.new + e.image = uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg") + e.file = uploaded_file(file_path("skanthak.png"), "image/png", "skanthak.png") + assert e.save + assert_match %{/entry/image/}, e.image + assert_match %{/entry/file/}, e.file + assert FileUtils.identical?(e.image, file_path("kerb.jpg")) + assert FileUtils.identical?(e.file, file_path("skanthak.png")) + end + + def test_with_two_models + e = Entry.new(:image => uploaded_file(file_path("kerb.jpg"), "image/jpeg", "kerb.jpg")) + m = Movie.new(:movie => uploaded_file(file_path("skanthak.png"), "image/png", "skanthak.png")) + assert e.save + assert m.save + assert_match %{/entry/image/}, e.image + assert_match %{/movie/movie/}, m.movie + assert FileUtils.identical?(e.image, file_path("kerb.jpg")) + assert FileUtils.identical?(m.movie, file_path("skanthak.png")) + end + + def test_no_file_uploaded + e = Entry.new + assert_nothing_raised { e.image = + uploaded_file(nil, "application/octet-stream", "", :stringio) } + assert_equal nil, e.image + end + + # when safari submits a form where no file has been + # selected, it does not transmit a content-type and + # the result is an empty string "" + def test_no_file_uploaded_with_safari + e = Entry.new + assert_nothing_raised { e.image = "" } + assert_equal nil, e.image + end + + def test_detect_wrong_encoding + e = Entry.new + assert_raise(TypeError) { e.image ="img42.jpg" } + end + + def test_serializable_before_save + e = Entry.new + e.image = uploaded_file(file_path("skanthak.png"), "image/png", "skanthak.png") + assert_nothing_raised { + flash = Marshal.dump(e) + e = Marshal.load(flash) + } + assert File.exists?(e.image) + end + + def test_should_call_after_upload_on_new_upload + Entry.file_column :image, :after_upload => [:after_assign] + e = Entry.new + e.image = upload(f("skanthak.png")) + assert e.after_assign_called? + end + + def test_should_call_user_after_save_on_save + e = Entry.new(:image => upload(f("skanthak.png"))) + assert e.save + + assert_kind_of FileColumn::PermanentUploadedFile, e.send(:image_state) + assert e.after_save_called? + end + + + def test_assign_standard_files + e = Entry.new + e.image = File.new(file_path('skanthak.png')) + + assert_equal 'skanthak.png', File.basename(e.image) + assert FileUtils.identical?(file_path('skanthak.png'), e.image) + + assert e.save + end + + + def test_validates_filesize + Entry.validates_filesize_of :image, :in => 50.kilobytes..100.kilobytes + + e = Entry.new(:image => upload(f("kerb.jpg"))) + assert e.save + + e.image = upload(f("skanthak.png")) + assert !e.save + assert e.errors.invalid?("image") + end + + def test_validates_file_format_simple + e = Entry.new(:image => upload(f("skanthak.png"))) + assert e.save + + Entry.validates_file_format_of :image, :in => ["jpg"] + + e.image = upload(f("kerb.jpg")) + assert e.save + + e.image = upload(f("mysql.sql")) + assert !e.save + assert e.errors.invalid?("image") + + end + + def test_validates_image_size + Entry.validates_image_size :image, :min => "640x480" + + e = Entry.new(:image => upload(f("kerb.jpg"))) + assert e.save + + e = Entry.new(:image => upload(f("skanthak.png"))) + assert !e.save + assert e.errors.invalid?("image") + end + + def do_permission_test(uploaded_file, permissions=0641) + Entry.file_column :image, :permissions => permissions + + e = Entry.new(:image => uploaded_file) + assert e.save + + assert_equal permissions, (File.stat(e.image).mode & 0777) + end + + def test_permissions_with_small_file + do_permission_test upload(f("skanthak.png"), :guess, :stringio) + end + + def test_permission_with_big_file + do_permission_test upload(f("kerb.jpg")) + end + + def test_permission_that_overrides_umask + do_permission_test upload(f("skanthak.png"), :guess, :stringio), 0666 + do_permission_test upload(f("kerb.jpg")), 0666 + end + + def test_access_with_empty_id + # an empty id might happen after a clone or through some other + # strange event. Since we would create a path that contains nothing + # where the id would have been, we should fail fast with an exception + # in this case + + e = Entry.new(:image => upload(f("skanthak.png"))) + assert e.save + id = e.id + + e = Entry.find(id) + + e["id"] = "" + assert_raise(RuntimeError) { e.image } + + e = Entry.find(id) + e["id"] = nil + assert_raise(RuntimeError) { e.image } + end +end + +# Tests for moving temp dir to permanent dir +class FileColumnMoveTest < Test::Unit::TestCase + + def setup + # we define the file_columns here so that we can change + # settings easily in a single test + + Entry.file_column :image + + end + + def teardown + FileUtils.rm_rf File.dirname(__FILE__)+"/public/entry/" + end + + def test_should_move_additional_files_from_tmp + e = Entry.new + e.image = uploaded_file(file_path("skanthak.png"), "image/png", "skanthak.png") + FileUtils.cp file_path("kerb.jpg"), File.dirname(e.image) + assert e.save + dir = File.dirname(e.image) + assert File.exists?(File.join(dir, "skanthak.png")) + assert File.exists?(File.join(dir, "kerb.jpg")) + end + + def test_should_move_direcotries_on_save + e = Entry.new(:image => upload(f("skanthak.png"))) + + FileUtils.mkdir( e.image_dir+"/foo" ) + FileUtils.cp file_path("kerb.jpg"), e.image_dir+"/foo/kerb.jpg" + + assert e.save + + assert File.exists?(e.image) + assert File.exists?(File.dirname(e.image)+"/foo/kerb.jpg") + end + + def test_should_overwrite_dirs_with_files_on_reupload + e = Entry.new(:image => upload(f("skanthak.png"))) + + FileUtils.mkdir( e.image_dir+"/kerb.jpg") + FileUtils.cp file_path("kerb.jpg"), e.image_dir+"/kerb.jpg/" + assert e.save + + e.image = upload(f("kerb.jpg")) + assert e.save + + assert File.file?(e.image_dir+"/kerb.jpg") + end + + def test_should_overwrite_files_with_dirs_on_reupload + e = Entry.new(:image => upload(f("skanthak.png"))) + + assert e.save + assert File.file?(e.image_dir+"/skanthak.png") + + e.image = upload(f("kerb.jpg")) + FileUtils.mkdir(e.image_dir+"/skanthak.png") + + assert e.save + assert File.file?(e.image_dir+"/kerb.jpg") + assert !File.file?(e.image_dir+"/skanthak.png") + assert File.directory?(e.image_dir+"/skanthak.png") + end + +end + diff --git a/vendor/plugins/file_column/test/fixtures/entry.rb b/vendor/plugins/file_column/test/fixtures/entry.rb new file mode 100644 index 000000000..b9f7c954d --- /dev/null +++ b/vendor/plugins/file_column/test/fixtures/entry.rb @@ -0,0 +1,32 @@ +class Entry < ActiveRecord::Base + attr_accessor :validation_should_fail + + def validate + errors.add("image","some stupid error") if @validation_should_fail + end + + def after_assign + @after_assign_called = true + end + + def after_assign_called? + @after_assign_called + end + + def after_save + @after_save_called = true + end + + def after_save_called? + @after_save_called + end + + def my_store_dir + # not really dynamic but at least it could be... + "my_store_dir" + end + + def load_image_with_rmagick(path) + Magick::Image::read(path).first + end +end diff --git a/vendor/plugins/file_column/test/fixtures/invalid-image.jpg b/vendor/plugins/file_column/test/fixtures/invalid-image.jpg new file mode 100644 index 000000000..bd4933b43 --- /dev/null +++ b/vendor/plugins/file_column/test/fixtures/invalid-image.jpg @@ -0,0 +1 @@ +this is certainly not a JPEG image diff --git a/vendor/plugins/file_column/test/fixtures/kerb.jpg b/vendor/plugins/file_column/test/fixtures/kerb.jpg new file mode 100644 index 000000000..083138e4f Binary files /dev/null and b/vendor/plugins/file_column/test/fixtures/kerb.jpg differ diff --git a/vendor/plugins/file_column/test/fixtures/mysql.sql b/vendor/plugins/file_column/test/fixtures/mysql.sql new file mode 100644 index 000000000..55143f27b --- /dev/null +++ b/vendor/plugins/file_column/test/fixtures/mysql.sql @@ -0,0 +1,25 @@ +-- MySQL dump 9.11 +-- +-- Host: localhost Database: file_column_test +-- ------------------------------------------------------ +-- Server version 4.0.24 + +-- +-- Table structure for table `entries` +-- + +DROP TABLE IF EXISTS entries; +CREATE TABLE entries ( + id int(11) NOT NULL auto_increment, + image varchar(200) default NULL, + file varchar(200) NOT NULL, + PRIMARY KEY (id) +) TYPE=MyISAM; + +DROP TABLE IF EXISTS movies; +CREATE TABLE movies ( + id int(11) NOT NULL auto_increment, + movie varchar(200) default NULL, + PRIMARY KEY (id) +) TYPE=MyISAM; + diff --git a/vendor/plugins/file_column/test/fixtures/schema.rb b/vendor/plugins/file_column/test/fixtures/schema.rb new file mode 100644 index 000000000..49b5ddbaa --- /dev/null +++ b/vendor/plugins/file_column/test/fixtures/schema.rb @@ -0,0 +1,10 @@ +ActiveRecord::Schema.define do + create_table :entries, :force => true do |t| + t.column :image, :string, :null => true + t.column :file, :string, :null => false + end + + create_table :movies, :force => true do |t| + t.column :movie, :string + end +end diff --git a/vendor/plugins/file_column/test/fixtures/skanthak.png b/vendor/plugins/file_column/test/fixtures/skanthak.png new file mode 100644 index 000000000..7415eb6e4 Binary files /dev/null and b/vendor/plugins/file_column/test/fixtures/skanthak.png differ diff --git a/vendor/plugins/file_column/test/magick_test.rb b/vendor/plugins/file_column/test/magick_test.rb new file mode 100644 index 000000000..036271927 --- /dev/null +++ b/vendor/plugins/file_column/test/magick_test.rb @@ -0,0 +1,380 @@ +require File.dirname(__FILE__) + '/abstract_unit' +require 'RMagick' +require File.dirname(__FILE__) + '/fixtures/entry' + + +class AbstractRMagickTest < Test::Unit::TestCase + def teardown + FileUtils.rm_rf File.dirname(__FILE__)+"/public/entry/" + end + + def test_truth + assert true + end + + private + + def read_image(path) + Magick::Image::read(path).first + end + + def assert_max_image_size(img, s) + assert img.columns <= s, "img has #{img.columns} columns, expected: #{s}" + assert img.rows <= s, "img has #{img.rows} rows, expected: #{s}" + assert_equal s, [img.columns, img.rows].max + end +end + +class RMagickSimpleTest < AbstractRMagickTest + def setup + Entry.file_column :image, :magick => { :geometry => "100x100" } + end + + def test_simple_resize_without_save + e = Entry.new + e.image = upload(f("kerb.jpg")) + + img = read_image(e.image) + assert_max_image_size img, 100 + end + + def test_simple_resize_with_save + e = Entry.new + e.image = upload(f("kerb.jpg")) + assert e.save + e.reload + + img = read_image(e.image) + assert_max_image_size img, 100 + end + + def test_resize_on_saved_image + Entry.file_column :image, :magick => { :geometry => "100x100" } + + e = Entry.new + e.image = upload(f("skanthak.png")) + assert e.save + e.reload + old_path = e.image + + e.image = upload(f("kerb.jpg")) + assert e.save + assert "kerb.jpg", File.basename(e.image) + assert !File.exists?(old_path), "old image '#{old_path}' still exists" + + img = read_image(e.image) + assert_max_image_size img, 100 + end + + def test_invalid_image + e = Entry.new + assert_nothing_raised { e.image = upload(f("invalid-image.jpg")) } + assert !e.valid? + end + + def test_serializable + e = Entry.new + e.image = upload(f("skanthak.png")) + assert_nothing_raised { + flash = Marshal.dump(e) + e = Marshal.load(flash) + } + assert File.exists?(e.image) + end + + def test_imagemagick_still_usable + e = Entry.new + assert_nothing_raised { + img = e.load_image_with_rmagick(file_path("skanthak.png")) + assert img.kind_of?(Magick::Image) + } + end +end + +class RMagickRequiresImageTest < AbstractRMagickTest + def setup + Entry.file_column :image, :magick => { + :size => "100x100>", + :image_required => false, + :versions => { + :thumb => "80x80>", + :large => {:size => "200x200>", :lazy => true} + } + } + end + + def test_image_required_with_image + e = Entry.new(:image => upload(f("skanthak.png"))) + assert_max_image_size read_image(e.image), 100 + assert e.valid? + end + + def test_image_required_with_invalid_image + e = Entry.new(:image => upload(f("invalid-image.jpg"))) + assert e.valid?, "did not ignore invalid image" + assert FileUtils.identical?(e.image, f("invalid-image.jpg")), "uploaded file has not been left alone" + end + + def test_versions_with_invalid_image + e = Entry.new(:image => upload(f("invalid-image.jpg"))) + assert e.valid? + + image_state = e.send(:image_state) + assert_nil image_state.create_magick_version_if_needed(:thumb) + assert_nil image_state.create_magick_version_if_needed(:large) + assert_nil image_state.create_magick_version_if_needed("300x300>") + end +end + +class RMagickCustomAttributesTest < AbstractRMagickTest + def assert_image_property(img, property, value, text = nil) + assert File.exists?(img), "the image does not exist" + assert_equal value, read_image(img).send(property), text + end + + def test_simple_attributes + Entry.file_column :image, :magick => { :attributes => { :quality => 20 } } + e = Entry.new("image" => upload(f("kerb.jpg"))) + assert_image_property e.image, :quality, 20, "the quality was not set" + end + + def test_version_attributes + Entry.file_column :image, :magick => { + :versions => { + :thumb => { :attributes => { :quality => 20 } } + } + } + e = Entry.new("image" => upload(f("kerb.jpg"))) + assert_image_property e.image("thumb"), :quality, 20, "the quality was not set" + end + + def test_lazy_attributes + Entry.file_column :image, :magick => { + :versions => { + :thumb => { :attributes => { :quality => 20 }, :lazy => true } + } + } + e = Entry.new("image" => upload(f("kerb.jpg"))) + e.send(:image_state).create_magick_version_if_needed(:thumb) + assert_image_property e.image("thumb"), :quality, 20, "the quality was not set" + end +end + +class RMagickVersionsTest < AbstractRMagickTest + def setup + Entry.file_column :image, :magick => {:geometry => "200x200", + :versions => { + :thumb => "50x50", + :medium => {:geometry => "100x100", :name => "100_100"}, + :large => {:geometry => "150x150", :lazy => true} + } + } + end + + + def test_should_create_thumb + e = Entry.new("image" => upload(f("skanthak.png"))) + + assert File.exists?(e.image("thumb")), "thumb-nail not created" + + assert_max_image_size read_image(e.image("thumb")), 50 + end + + def test_version_name_can_be_different_from_key + e = Entry.new("image" => upload(f("skanthak.png"))) + + assert File.exists?(e.image("100_100")) + assert !File.exists?(e.image("medium")) + end + + def test_should_not_create_lazy_versions + e = Entry.new("image" => upload(f("skanthak.png"))) + assert !File.exists?(e.image("large")), "lazy versions should not be created unless needed" + end + + def test_should_create_lazy_version_on_demand + e = Entry.new("image" => upload(f("skanthak.png"))) + + e.send(:image_state).create_magick_version_if_needed(:large) + + assert File.exists?(e.image("large")), "lazy version should be created on demand" + + assert_max_image_size read_image(e.image("large")), 150 + end + + def test_generated_name_should_not_change + e = Entry.new("image" => upload(f("skanthak.png"))) + + name1 = e.send(:image_state).create_magick_version_if_needed("50x50") + name2 = e.send(:image_state).create_magick_version_if_needed("50x50") + name3 = e.send(:image_state).create_magick_version_if_needed(:geometry => "50x50") + assert_equal name1, name2, "hash value has changed" + assert_equal name1, name3, "hash value has changed" + end + + def test_should_create_version_with_string + e = Entry.new("image" => upload(f("skanthak.png"))) + + name = e.send(:image_state).create_magick_version_if_needed("32x32") + + assert File.exists?(e.image(name)) + + assert_max_image_size read_image(e.image(name)), 32 + end + + def test_should_create_safe_auto_id + e = Entry.new("image" => upload(f("skanthak.png"))) + + name = e.send(:image_state).create_magick_version_if_needed("32x32") + + assert_match /^[a-zA-Z0-9]+$/, name + end +end + +class RMagickCroppingTest < AbstractRMagickTest + def setup + Entry.file_column :image, :magick => {:geometry => "200x200", + :versions => { + :thumb => {:crop => "1:1", :geometry => "50x50"} + } + } + end + + def test_should_crop_image_on_upload + e = Entry.new("image" => upload(f("skanthak.png"))) + + img = read_image(e.image("thumb")) + + assert_equal 50, img.rows + assert_equal 50, img.columns + end + +end + +class UrlForImageColumnTest < AbstractRMagickTest + include FileColumnHelper + + def setup + Entry.file_column :image, :magick => { + :versions => {:thumb => "50x50"} + } + @request = RequestMock.new + end + + def test_should_use_version_on_symbol_option + e = Entry.new(:image => upload(f("skanthak.png"))) + + url = url_for_image_column(e, "image", :thumb) + assert_match %r{^/entry/image/tmp/.+/thumb/skanthak.png$}, url + end + + def test_should_use_string_as_size + e = Entry.new(:image => upload(f("skanthak.png"))) + + url = url_for_image_column(e, "image", "50x50") + + assert_match %r{^/entry/image/tmp/.+/.+/skanthak.png$}, url + + url =~ /\/([^\/]+)\/skanthak.png$/ + dirname = $1 + + assert_max_image_size read_image(e.image(dirname)), 50 + end + + def test_should_accept_version_hash + e = Entry.new(:image => upload(f("skanthak.png"))) + + url = url_for_image_column(e, "image", :size => "50x50", :crop => "1:1", :name => "small") + + assert_match %r{^/entry/image/tmp/.+/small/skanthak.png$}, url + + img = read_image(e.image("small")) + assert_equal 50, img.rows + assert_equal 50, img.columns + end +end + +class RMagickPermissionsTest < AbstractRMagickTest + def setup + Entry.file_column :image, :magick => {:geometry => "200x200", + :versions => { + :thumb => {:crop => "1:1", :geometry => "50x50"} + } + }, :permissions => 0616 + end + + def check_permissions(e) + assert_equal 0616, (File.stat(e.image).mode & 0777) + assert_equal 0616, (File.stat(e.image("thumb")).mode & 0777) + end + + def test_permissions_with_rmagick + e = Entry.new(:image => upload(f("skanthak.png"))) + + check_permissions e + + assert e.save + + check_permissions e + end +end + +class Entry + def transform_grey(img) + img.quantize(256, Magick::GRAYColorspace) + end +end + +class RMagickTransformationTest < AbstractRMagickTest + def assert_transformed(image) + assert File.exists?(image), "the image does not exist" + assert 256 > read_image(image).number_colors, "the number of colors was not changed" + end + + def test_simple_transformation + Entry.file_column :image, :magick => { :transformation => Proc.new { |image| image.quantize(256, Magick::GRAYColorspace) } } + e = Entry.new("image" => upload(f("skanthak.png"))) + assert_transformed(e.image) + end + + def test_simple_version_transformation + Entry.file_column :image, :magick => { + :versions => { :thumb => Proc.new { |image| image.quantize(256, Magick::GRAYColorspace) } } + } + e = Entry.new("image" => upload(f("skanthak.png"))) + assert_transformed(e.image("thumb")) + end + + def test_complex_version_transformation + Entry.file_column :image, :magick => { + :versions => { + :thumb => { :transformation => Proc.new { |image| image.quantize(256, Magick::GRAYColorspace) } } + } + } + e = Entry.new("image" => upload(f("skanthak.png"))) + assert_transformed(e.image("thumb")) + end + + def test_lazy_transformation + Entry.file_column :image, :magick => { + :versions => { + :thumb => { :transformation => Proc.new { |image| image.quantize(256, Magick::GRAYColorspace) }, :lazy => true } + } + } + e = Entry.new("image" => upload(f("skanthak.png"))) + e.send(:image_state).create_magick_version_if_needed(:thumb) + assert_transformed(e.image("thumb")) + end + + def test_simple_callback_transformation + Entry.file_column :image, :magick => :transform_grey + e = Entry.new(:image => upload(f("skanthak.png"))) + assert_transformed(e.image) + end + + def test_complex_callback_transformation + Entry.file_column :image, :magick => { :transformation => :transform_grey } + e = Entry.new(:image => upload(f("skanthak.png"))) + assert_transformed(e.image) + end +end diff --git a/vendor/plugins/file_column/test/magick_view_only_test.rb b/vendor/plugins/file_column/test/magick_view_only_test.rb new file mode 100644 index 000000000..a7daa6172 --- /dev/null +++ b/vendor/plugins/file_column/test/magick_view_only_test.rb @@ -0,0 +1,21 @@ +require File.dirname(__FILE__) + '/abstract_unit' +require File.dirname(__FILE__) + '/fixtures/entry' + +class RMagickViewOnlyTest < Test::Unit::TestCase + include FileColumnHelper + + def setup + Entry.file_column :image + @request = RequestMock.new + end + + def teardown + FileUtils.rm_rf File.dirname(__FILE__)+"/public/entry/" + end + + def test_url_for_image_column_without_model_versions + e = Entry.new(:image => upload(f("skanthak.png"))) + + assert_nothing_raised { url_for_image_column e, "image", "50x50" } + end +end diff --git a/vendor/plugins/sql_session_store/LICENSE b/vendor/plugins/sql_session_store/LICENSE new file mode 100644 index 000000000..5cb5c7b95 --- /dev/null +++ b/vendor/plugins/sql_session_store/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2006-2008 Dr.-Ing. Stefan Kaes + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/plugins/sql_session_store/README b/vendor/plugins/sql_session_store/README new file mode 100755 index 000000000..07b083343 --- /dev/null +++ b/vendor/plugins/sql_session_store/README @@ -0,0 +1,60 @@ +== SqlSessionStore + +See http://railsexpress.de/blog/articles/2005/12/19/roll-your-own-sql-session-store + +Only Mysql, Postgres and Oracle are currently supported (others work, +but you won't see much performance improvement). + +== Step 1 + +If you have generated your sessions table using rake db:sessions:create, go to Step 2 + +If you're using an old version of sql_session_store, run + script/generate sql_session_store DB +where DB is mysql, postgresql or oracle + +Then run + rake migrate +or + rake db:migrate +for edge rails. + +== Step 2 + +Add the code below after the initializer config section: + + ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS. + update(:database_manager => SqlSessionStore) + +Finally, depending on your database type, add + + SqlSessionStore.session_class = MysqlSession +or + + SqlSessionStore.session_class = PostgresqlSession +or + SqlSessionStore.session_class = OracleSession + +after the initializer section in environment.rb + +== Step 3 (optional) + +If you want to use a database separate from your default one to store +your sessions, specify a configuration in your database.yml file (say +sessions), and establish the connection on SqlSession in +environment.rb: + + SqlSession.establish_connection :sessions + + +== IMPORTANT NOTES + +1. The class name SQLSessionStore has changed to SqlSessionStore to + let Rails work its autoload magic. + +2. You will need the binary drivers for Mysql or Postgresql. + These have been verified to work: + + * ruby-postgres (0.7.1.2005.12.21) with postgreql 8.1 + * ruby-mysql 2.7.1 with Mysql 4.1 + * ruby-mysql 2.7.2 with Mysql 5.0 diff --git a/vendor/plugins/sql_session_store/Rakefile b/vendor/plugins/sql_session_store/Rakefile new file mode 100755 index 000000000..0145def2f --- /dev/null +++ b/vendor/plugins/sql_session_store/Rakefile @@ -0,0 +1,22 @@ +require 'rake' +require 'rake/testtask' +require 'rake/rdoctask' + +desc 'Default: run unit tests.' +task :default => :test + +desc 'Test the sql_session_store plugin.' +Rake::TestTask.new(:test) do |t| + t.libs << 'lib' + t.pattern = 'test/**/*_test.rb' + t.verbose = true +end + +desc 'Generate documentation for the sql_session_store plugin.' +Rake::RDocTask.new(:rdoc) do |rdoc| + rdoc.rdoc_dir = 'rdoc' + rdoc.title = 'SqlSessionStore' + rdoc.options << '--line-numbers' << '--inline-source' + rdoc.rdoc_files.include('README') + rdoc.rdoc_files.include('lib/**/*.rb') +end diff --git a/vendor/plugins/sql_session_store/generators/sql_session_store/USAGE b/vendor/plugins/sql_session_store/generators/sql_session_store/USAGE new file mode 100755 index 000000000..1e3f58a67 --- /dev/null +++ b/vendor/plugins/sql_session_store/generators/sql_session_store/USAGE @@ -0,0 +1,17 @@ +Description: + The sql_session_store generator creates a migration for use with + the sql session store. It takes one argument: the database + type. Only mysql and postgreql are currently supported. + +Example: + ./script/generate sql_session_store mysql + + This will create the following migration: + + db/migrate/XXX_add_sql_session.rb + + Use + + ./script/generate sql_session_store postgreql + + to get a migration for postgres. diff --git a/vendor/plugins/sql_session_store/generators/sql_session_store/sql_session_store_generator.rb b/vendor/plugins/sql_session_store/generators/sql_session_store/sql_session_store_generator.rb new file mode 100755 index 000000000..6af6bd0bc --- /dev/null +++ b/vendor/plugins/sql_session_store/generators/sql_session_store/sql_session_store_generator.rb @@ -0,0 +1,25 @@ +class SqlSessionStoreGenerator < Rails::Generator::NamedBase + def initialize(runtime_args, runtime_options = {}) + runtime_args.insert(0, 'add_sql_session') + if runtime_args.include?('postgresql') + @_database = 'postgresql' + elsif runtime_args.include?('mysql') + @_database = 'mysql' + elsif runtime_args.include?('oracle') + @_database = 'oracle' + else + puts "error: database type not given.\nvalid arguments are: mysql or postgresql" + exit + end + super + end + + def manifest + record do |m| + m.migration_template("migration.rb", 'db/migrate', + :assigns => { :migration_name => "SqlSessionStoreSetup", :database => @_database }, + :migration_file_name => "sql_session_store_setup" + ) + end + end +end diff --git a/vendor/plugins/sql_session_store/generators/sql_session_store/templates/migration.rb b/vendor/plugins/sql_session_store/generators/sql_session_store/templates/migration.rb new file mode 100755 index 000000000..512650068 --- /dev/null +++ b/vendor/plugins/sql_session_store/generators/sql_session_store/templates/migration.rb @@ -0,0 +1,38 @@ +class <%= migration_name %> < ActiveRecord::Migration + + class Session < ActiveRecord::Base; end + + def self.up + c = ActiveRecord::Base.connection + if c.tables.include?('sessions') + if (columns = Session.column_names).include?('sessid') + rename_column :sessions, :sessid, :session_id + else + add_column :sessions, :session_id, :string unless columns.include?('session_id') + add_column :sessions, :data, :text unless columns.include?('data') + if columns.include?('created_on') + rename_column :sessions, :created_on, :created_at + else + add_column :sessions, :created_at, :timestamp unless columns.include?('created_at') + end + if columns.include?('updated_on') + rename_column :sessions, :updated_on, :updated_at + else + add_column :sessions, :updated_at, :timestamp unless columns.include?('updated_at') + end + end + else + create_table :sessions, :options => '<%= database == "mysql" ? "ENGINE=MyISAM" : "" %>' do |t| + t.column :session_id, :string + t.column :data, :text + t.column :created_at, :timestamp + t.column :updated_at, :timestamp + end + add_index :sessions, :session_id, :name => 'session_id_idx' + end + end + + def self.down + raise IrreversibleMigration + end +end diff --git a/vendor/plugins/sql_session_store/init.rb b/vendor/plugins/sql_session_store/init.rb new file mode 100755 index 000000000..956151ea7 --- /dev/null +++ b/vendor/plugins/sql_session_store/init.rb @@ -0,0 +1 @@ +require 'sql_session_store' diff --git a/vendor/plugins/sql_session_store/install.rb b/vendor/plugins/sql_session_store/install.rb new file mode 100755 index 000000000..f40549dfe --- /dev/null +++ b/vendor/plugins/sql_session_store/install.rb @@ -0,0 +1,2 @@ +# Install hook code here +puts IO.read(File.join(File.dirname(__FILE__), 'README')) diff --git a/vendor/plugins/sql_session_store/lib/mysql_session.rb b/vendor/plugins/sql_session_store/lib/mysql_session.rb new file mode 100755 index 000000000..8c86384c9 --- /dev/null +++ b/vendor/plugins/sql_session_store/lib/mysql_session.rb @@ -0,0 +1,132 @@ +require 'mysql' + +# allow access to the real Mysql connection +class ActiveRecord::ConnectionAdapters::MysqlAdapter + attr_reader :connection +end + +# MysqlSession is a down to the bare metal session store +# implementation to be used with +SQLSessionStore+. It is much faster +# than the default ActiveRecord implementation. +# +# The implementation assumes that the table column names are 'id', +# 'data', 'created_at' and 'updated_at'. If you want use other names, +# you will need to change the SQL statments in the code. + +class MysqlSession + + # if you need Rails components, and you have a pages which create + # new sessions, and embed components insides this pages that need + # session access, then you *must* set +eager_session_creation+ to + # true (as of Rails 1.0). + cattr_accessor :eager_session_creation + @@eager_session_creation = false + + attr_accessor :id, :session_id, :data + + def initialize(session_id, data) + @session_id = session_id + @data = data + @id = nil + end + + class << self + + # retrieve the session table connection and get the 'raw' Mysql connection from it + def session_connection + SqlSession.connection.connection + end + + # try to find a session with a given +session_id+. returns nil if + # no such session exists. note that we don't retrieve + # +created_at+ and +updated_at+ as they are not accessed anywhyere + # outside this class + def find_session(session_id) + connection = session_connection + connection.query_with_result = true + session_id = Mysql::quote(session_id) + result = connection.query("SELECT id, data FROM sessions WHERE `session_id`='#{session_id}' LIMIT 1") + my_session = nil + # each is used below, as other methods barf on my 64bit linux machine + # I suspect this to be a bug in mysql-ruby + result.each do |row| + my_session = new(session_id, row[1]) + my_session.id = row[0] + end + result.free + my_session + end + + # create a new session with given +session_id+ and +data+ + # and save it immediately to the database + def create_session(session_id, data) + session_id = Mysql::quote(session_id) + new_session = new(session_id, data) + if @@eager_session_creation + connection = session_connection + connection.query("INSERT INTO sessions (`created_at`, `updated_at`, `session_id`, `data`) VALUES (NOW(), NOW(), '#{session_id}', '#{Mysql::quote(data)}')") + new_session.id = connection.insert_id + end + new_session + end + + # delete all sessions meeting a given +condition+. it is the + # caller's responsibility to pass a valid sql condition + def delete_all(condition=nil) + if condition + session_connection.query("DELETE FROM sessions WHERE #{condition}") + else + session_connection.query("DELETE FROM sessions") + end + end + + end # class methods + + # update session with given +data+. + # unlike the default implementation using ActiveRecord, updating of + # column `updated_at` will be done by the datbase itself + def update_session(data) + connection = self.class.session_connection + if @id + # if @id is not nil, this is a session already stored in the database + # update the relevant field using @id as key + connection.query("UPDATE sessions SET `updated_at`=NOW(), `data`='#{Mysql::quote(data)}' WHERE id=#{@id}") + else + # if @id is nil, we need to create a new session in the database + # and set @id to the primary key of the inserted record + connection.query("INSERT INTO sessions (`created_at`, `updated_at`, `session_id`, `data`) VALUES (NOW(), NOW(), '#{@session_id}', '#{Mysql::quote(data)}')") + @id = connection.insert_id + end + end + + # destroy the current session + def destroy + self.class.delete_all("session_id='#{session_id}'") + end + +end + +__END__ + +# This software is released under the MIT license +# +# Copyright (c) 2005-2008 Stefan Kaes + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/plugins/sql_session_store/lib/oracle_session.rb b/vendor/plugins/sql_session_store/lib/oracle_session.rb new file mode 100755 index 000000000..0b82f6391 --- /dev/null +++ b/vendor/plugins/sql_session_store/lib/oracle_session.rb @@ -0,0 +1,143 @@ +require 'oci8' + +# allow access to the real Oracle connection +class ActiveRecord::ConnectionAdapters::OracleAdapter + attr_reader :connection +end + +# OracleSession is a down to the bare metal session store +# implementation to be used with +SQLSessionStore+. It is much faster +# than the default ActiveRecord implementation. +# +# The implementation assumes that the table column names are 'id', +# 'session_id', 'data', 'created_at' and 'updated_at'. If you want use +# other names, you will need to change the SQL statments in the code. +# +# This table layout is compatible with ActiveRecordStore. + +class OracleSession + + # if you need Rails components, and you have a pages which create + # new sessions, and embed components insides these pages that need + # session access, then you *must* set +eager_session_creation+ to + # true (as of Rails 1.0). Not needed for Rails 1.1 and up. + cattr_accessor :eager_session_creation + @@eager_session_creation = false + + attr_accessor :id, :session_id, :data + + def initialize(session_id, data) + @session_id = session_id + @data = data + @id = nil + end + + class << self + + # retrieve the session table connection and get the 'raw' Oracle connection from it + def session_connection + SqlSession.connection.connection + end + + # try to find a session with a given +session_id+. returns nil if + # no such session exists. note that we don't retrieve + # +created_at+ and +updated_at+ as they are not accessed anywhyere + # outside this class. + def find_session(session_id) + new_session = nil + connection = session_connection + result = connection.exec("SELECT id, data FROM sessions WHERE session_id = :a and rownum=1", session_id) + + # Make sure to save the @id if we find an existing session + while row = result.fetch + new_session = new(session_id,row[1].read) + new_session.id = row[0] + end + result.close + new_session + end + + # create a new session with given +session_id+ and +data+ + # and save it immediately to the database + def create_session(session_id, data) + new_session = new(session_id, data) + if @@eager_session_creation + connection = session_connection + connection.exec("INSERT INTO sessions (id, created_at, updated_at, session_id, data)"+ + " VALUES (sessions_seq.nextval, SYSDATE, SYSDATE, :a, :b)", + session_id, data) + result = connection.exec("SELECT sessions_seq.currval FROM dual") + row = result.fetch + new_session.id = row[0].to_i + end + new_session + end + + # delete all sessions meeting a given +condition+. it is the + # caller's responsibility to pass a valid sql condition + def delete_all(condition=nil) + if condition + session_connection.exec("DELETE FROM sessions WHERE #{condition}") + else + session_connection.exec("DELETE FROM sessions") + end + end + + end # class methods + + # update session with given +data+. + # unlike the default implementation using ActiveRecord, updating of + # column `updated_at` will be done by the database itself + def update_session(data) + connection = self.class.session_connection + if @id + # if @id is not nil, this is a session already stored in the database + # update the relevant field using @id as key + connection.exec("UPDATE sessions SET updated_at = SYSDATE, data = :a WHERE id = :b", + data, @id) + else + # if @id is nil, we need to create a new session in the database + # and set @id to the primary key of the inserted record + connection.exec("INSERT INTO sessions (id, created_at, updated_at, session_id, data)"+ + " VALUES (sessions_seq.nextval, SYSDATE, SYSDATE, :a, :b)", + @session_id, data) + result = connection.exec("SELECT sessions_seq.currval FROM dual") + row = result.fetch + @id = row[0].to_i + end + end + + # destroy the current session + def destroy + self.class.delete_all("session_id='#{session_id}'") + end + +end + +__END__ + +# This software is released under the MIT license +# +# Copyright (c) 2006-2008 Stefan Kaes +# Copyright (c) 2006-2008 Tiago Macedo +# Copyright (c) 2007-2008 Nate Wiger +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/vendor/plugins/sql_session_store/lib/postgresql_session.rb b/vendor/plugins/sql_session_store/lib/postgresql_session.rb new file mode 100755 index 000000000..d922913aa --- /dev/null +++ b/vendor/plugins/sql_session_store/lib/postgresql_session.rb @@ -0,0 +1,136 @@ +require 'postgres' + +# allow access to the real Mysql connection +class ActiveRecord::ConnectionAdapters::PostgreSQLAdapter + attr_reader :connection +end + +# PostgresqlSession is a down to the bare metal session store +# implementation to be used with +SQLSessionStore+. It is much faster +# than the default ActiveRecord implementation. +# +# The implementation assumes that the table column names are 'id', +# 'session_id', 'data', 'created_at' and 'updated_at'. If you want use +# other names, you will need to change the SQL statments in the code. +# +# This table layout is compatible with ActiveRecordStore. + +class PostgresqlSession + + # if you need Rails components, and you have a pages which create + # new sessions, and embed components insides these pages that need + # session access, then you *must* set +eager_session_creation+ to + # true (as of Rails 1.0). Not needed for Rails 1.1 and up. + cattr_accessor :eager_session_creation + @@eager_session_creation = false + + attr_accessor :id, :session_id, :data + + def initialize(session_id, data) + @session_id = session_id + @data = data + @id = nil + end + + class << self + + # retrieve the session table connection and get the 'raw' Postgresql connection from it + def session_connection + SqlSession.connection.connection + end + + # try to find a session with a given +session_id+. returns nil if + # no such session exists. note that we don't retrieve + # +created_at+ and +updated_at+ as they are not accessed anywhyere + # outside this class. + def find_session(session_id) + connection = session_connection + # postgres adds string delimiters when quoting, so strip them off + session_id = PGconn::quote(session_id)[1..-2] + result = connection.query("SELECT id, data FROM sessions WHERE session_id='#{session_id}' LIMIT 1") + my_session = nil + # each is used below, as other methods barf on my 64bit linux machine + # I suspect this to be a bug in mysql-ruby + result.each do |row| + my_session = new(session_id, row[1]) + my_session.id = row[0] + end + result.clear + my_session + end + + # create a new session with given +session_id+ and +data+ + # and save it immediately to the database + def create_session(session_id, data) + # postgres adds string delimiters when quoting, so strip them off + session_id = PGconn::quote(session_id)[1..-2] + new_session = new(session_id, data) + if @@eager_session_creation + connection = session_connection + connection.query("INSERT INTO sessions (\"created_at\", \"updated_at\", \"session_id\", \"data\") VALUES (NOW(), NOW(), '#{session_id}', #{PGconn::quote(data)})") + new_session.id = connection.lastval + end + new_session + end + + # delete all sessions meeting a given +condition+. it is the + # caller's responsibility to pass a valid sql condition + def delete_all(condition=nil) + if condition + session_connection.query("DELETE FROM sessions WHERE #{condition}") + else + session_connection.query("DELETE FROM sessions") + end + end + + end # class methods + + # update session with given +data+. + # unlike the default implementation using ActiveRecord, updating of + # column `updated_at` will be done by the database itself + def update_session(data) + connection = self.class.session_connection + if @id + # if @id is not nil, this is a session already stored in the database + # update the relevant field using @id as key + connection.query("UPDATE sessions SET \"updated_at\"=NOW(), \"data\"=#{PGconn::quote(data)} WHERE id=#{@id}") + else + # if @id is nil, we need to create a new session in the database + # and set @id to the primary key of the inserted record + connection.query("INSERT INTO sessions (\"created_at\", \"updated_at\", \"session_id\", \"data\") VALUES (NOW(), NOW(), '#{@session_id}', #{PGconn::quote(data)})") + @id = connection.lastval rescue connection.query("select lastval()").first[0] + end + end + + # destroy the current session + def destroy + self.class.delete_all("session_id=#{PGconn.quote(session_id)}") + end + +end + +__END__ + +# This software is released under the MIT license +# +# Copyright (c) 2006-2008 Stefan Kaes + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/vendor/plugins/sql_session_store/lib/sql_session.rb b/vendor/plugins/sql_session_store/lib/sql_session.rb new file mode 100644 index 000000000..19d2ad51e --- /dev/null +++ b/vendor/plugins/sql_session_store/lib/sql_session.rb @@ -0,0 +1,27 @@ +# An ActiveRecord class which corresponds to the database table +# +sessions+. Functions +find_session+, +create_session+, +# +update_session+ and +destroy+ constitute the interface to class +# +SqlSessionStore+. + +class SqlSession < ActiveRecord::Base + # this class should not be reloaded + def self.reloadable? + false + end + + # retrieve session data for a given +session_id+ from the database, + # return nil if no such session exists + def self.find_session(session_id) + find :first, :conditions => "session_id='#{session_id}'" + end + + # create a new session with given +session_id+ and +data+ + def self.create_session(session_id, data) + new(:session_id => session_id, :data => data) + end + + # update session data and store it in the database + def update_session(data) + update_attribute('data', data) + end +end diff --git a/vendor/plugins/sql_session_store/lib/sql_session_store.rb b/vendor/plugins/sql_session_store/lib/sql_session_store.rb new file mode 100755 index 000000000..8b0ff156f --- /dev/null +++ b/vendor/plugins/sql_session_store/lib/sql_session_store.rb @@ -0,0 +1,116 @@ +require 'active_record' +require 'cgi' +require 'cgi/session' +begin + require 'base64' +rescue LoadError +end + +# +SqlSessionStore+ is a stripped down, optimized for speed version of +# class +ActiveRecordStore+. + +class SqlSessionStore + + # The class to be used for creating, retrieving and updating sessions. + # Defaults to SqlSessionStore::Session, which is derived from +ActiveRecord::Base+. + # + # In order to achieve acceptable performance you should implement + # your own session class, similar to the one provided for Myqsl. + # + # Only functions +find_session+, +create_session+, + # +update_session+ and +destroy+ are required. See file +mysql_session.rb+. + + cattr_accessor :session_class + @@session_class = SqlSession + + # Create a new SqlSessionStore instance. + # + # +session+ is the session for which this instance is being created. + # + # +option+ is currently ignored as no options are recognized. + + def initialize(session, option=nil) + if @session = @@session_class.find_session(session.session_id) + @data = unmarshalize(@session.data) + else + @session = @@session_class.create_session(session.session_id, marshalize({})) + @data = {} + end + end + + # Update the database and disassociate the session object + def close + if @session + @session.update_session(marshalize(@data)) + @session = nil + end + end + + # Delete the current session, disassociate and destroy session object + def delete + if @session + @session.destroy + @session = nil + end + end + + # Restore session data from the session object + def restore + if @session + @data = unmarshalize(@session.data) + end + end + + # Save session data in the session object + def update + if @session + @session.update_session(marshalize(@data)) + end + end + + private + if defined?(Base64) + def unmarshalize(data) + Marshal.load(Base64.decode64(data)) + end + + def marshalize(data) + Base64.encode64(Marshal.dump(data)) + end + else + def unmarshalize(data) + Marshal.load(data.unpack("m").first) + end + + def marshalize(data) + [Marshal.dump(data)].pack("m") + end + end + +end + +__END__ + +# This software is released under the MIT license +# +# Copyright (c) 2005-2008 Stefan Kaes + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/vendor/plugins/sql_session_store/lib/sqlite_session.rb b/vendor/plugins/sql_session_store/lib/sqlite_session.rb new file mode 100755 index 000000000..822b23231 --- /dev/null +++ b/vendor/plugins/sql_session_store/lib/sqlite_session.rb @@ -0,0 +1,133 @@ +require 'sqlite3' + +# allow access to the real Sqlite connection +#class ActiveRecord::ConnectionAdapters::SQLiteAdapter +# attr_reader :connection +#end + +# SqliteSession is a down to the bare metal session store +# implementation to be used with +SQLSessionStore+. It is much faster +# than the default ActiveRecord implementation. +# +# The implementation assumes that the table column names are 'id', +# 'data', 'created_at' and 'updated_at'. If you want use other names, +# you will need to change the SQL statments in the code. + +class SqliteSession + + # if you need Rails components, and you have a pages which create + # new sessions, and embed components insides this pages that need + # session access, then you *must* set +eager_session_creation+ to + # true (as of Rails 1.0). + cattr_accessor :eager_session_creation + @@eager_session_creation = false + + attr_accessor :id, :session_id, :data + + def initialize(session_id, data) + @session_id = session_id + @data = data + @id = nil + end + + class << self + + # retrieve the session table connection and get the 'raw' Sqlite connection from it + def session_connection + SqlSession.connection.instance_variable_get(:@connection) + end + + # try to find a session with a given +session_id+. returns nil if + # no such session exists. note that we don't retrieve + # +created_at+ and +updated_at+ as they are not accessed anywhyere + # outside this class + def find_session(session_id) + connection = session_connection + session_id = SQLite3::Database.quote(session_id) + result = connection.execute("SELECT id, data FROM sessions WHERE `session_id`='#{session_id}' LIMIT 1") + my_session = nil + # each is used below, as other methods barf on my 64bit linux machine + # I suspect this to be a bug in sqlite-ruby + result.each do |row| + my_session = new(session_id, row[1]) + my_session.id = row[0] + end +# result.free + my_session + end + + # create a new session with given +session_id+ and +data+ + # and save it immediately to the database + def create_session(session_id, data) + session_id = SQLite3::Database.quote(session_id) + new_session = new(session_id, data) + if @@eager_session_creation + connection = session_connection + connection.execute("INSERT INTO sessions ('id', `created_at`, `updated_at`, `session_id`, `data`) VALUES (NULL, datetime('now'), datetime('now'), '#{session_id}', '#{SQLite3::Database.quote(data)}')") + new_session.id = connection.last_insert_row_id() + end + new_session + end + + # delete all sessions meeting a given +condition+. it is the + # caller's responsibility to pass a valid sql condition + def delete_all(condition=nil) + if condition + session_connection.execute("DELETE FROM sessions WHERE #{condition}") + else + session_connection.execute("DELETE FROM sessions") + end + end + + end # class methods + + # update session with given +data+. + # unlike the default implementation using ActiveRecord, updating of + # column `updated_at` will be done by the database itself + def update_session(data) + connection = SqlSession.connection.instance_variable_get(:@connection) #self.class.session_connection + if @id + # if @id is not nil, this is a session already stored in the database + # update the relevant field using @id as key + connection.execute("UPDATE sessions SET `updated_at`=datetime('now'), `data`='#{SQLite3::Database.quote(data)}' WHERE id=#{@id}") + else + # if @id is nil, we need to create a new session in the database + # and set @id to the primary key of the inserted record + connection.execute("INSERT INTO sessions ('id', `created_at`, `updated_at`, `session_id`, `data`) VALUES (NULL, datetime('now'), datetime('now'), '#{@session_id}', '#{SQLite3::Database.quote(data)}')") + @id = connection.last_insert_row_id() + end + end + + # destroy the current session + def destroy + connection = SqlSession.connection.instance_variable_get(:@connection) + connection.execute("delete from sessions where session_id='#{session_id}'") + end + +end + +__END__ + +# This software is released under the MIT license +# +# Copyright (c) 2005-2008 Stefan Kaes +# Copyright (c) 2006-2008 Ted X Toth + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.