require 'rubygems' require 'mongo' require 'rack/session/abstract/id' module Rack module Session # Implements the +Rack::Session::Abstract::ID+ session store interface # # Cookies sent to the client for maintaining sessions will only contain an # id reference. See {Rack::Session::Mongo#initialize below} for options. # # == Usage Example # # use Rack::Session::Mongo, :connection => @existing_mongodb_connection;, # :expire_after => 1800 class Mongo < Abstract::ID attr_reader :mutex, :pool, :connection, :marshal_data DEFAULT_OPTIONS = Abstract::ID::DEFAULT_OPTIONS.merge :db => 'rack', :collection => 'sessions', :drop => false # Creates a new Mongo session store pool. You probably won't initialize # it his way. See the overview for standard usage instructions. # # Unless specified, the options can be set on a per request basis, in the # +rack.session.options+ environment hash. Additionally the id of # the session can be found within the options hash at the key +:id+. It is # highly not recommended to change its value. # # @param app a Rack application # @param [Hash] options configuration for the session pool # @option options [Mongo::Connection] :connection (Mongo::Connection.new) # used to create the pool. Change this if you already have a connection # setup, or want to connect to a server other than +localhost+. # — pool instance global # @option options [String] :db ('rack') the Mongo db to use — pool # instance global # @option options [String] :collection ('sessions') the Mongo collection # to use. — pool instance global # @option options [boolean] :marshal_data (true) Marshal data into string # otherwise store as hash in db. — pool instance global # Note: if you use this then the keys used to lookup values must be strings # even if you put in symbols. *Example:* # request1: session[:test] = true # request2: session[:test] # > nil # session['test'] # > true # # The advantage is that you can query the contents of the sessions and # potentially make changes on the fly # @option options [Integer] :expire_after (nil) the time in seconds for # the session to last for. *Example:* If this is set to +1800+, the # session will be deleted if the client doesn't make a request within 30 # minutes of its last request. # @option options [Integer] :clear_expired_after (1800) the time in seconds # before we clear out old sessions. *Example:* If this is set to +1800+, the # the session will be cleared from mongodb when a session is requested, if # it has been 1800 seconds since it was last cleared. setting to -1 will # disable this. # @option options [true, false] :defer (false) don't set the session # cookie for this request. # @option options [true, false] :renew (false) causes the generation of # a new session id, and migrates the data to id. Overrides +:defer+. # @option options [true, false] :drop (false) destroys the current # session, and creates a new one. # @option options [String] :key ('rack.session') the name of the cookie # that stores the session id # @option options [String] :path ('/') the cookie path # @option options [String] :domain (nil) the cookie domain # @option options [true, false] :secure (false) the cookie security flag; # tells the client to only send the cookie over HTTPS. # @option options [true, false] :httponly (true) the cookie HttpOnly flag; # makes the cookie invisible to client-side Javascript. def initialize(app, options = {}) super @mutex = Mutex.new @connection = @default_options[:connection] || ::Mongo::Connection.new @pool = @connection.db(@default_options[:db]).collection(@default_options[:collection]) @pool.create_index([['expires', -1]]) @pool.create_index('sid', :unique => true) @marshal_data = @default_options[:marshal_data].nil? ? true : @default_options[:marshal_data] == true @next_expire_period = nil @recheck_expire_period = @default_options[:clear_expired_after].nil? ? 1800 : @default_options[:clear_expired_after].to_i end def get_session(env, sid) @mutex.lock if env['rack.multithread'] session = find_session(sid) if sid unless sid and session env['rack.errors'].puts("Session '#{sid}' not found, initializing...") if $VERBOSE and not sid.nil? session = {} sid = generate_sid save_session(sid) end session.instance_variable_set('@old', {}.merge(session)) session.instance_variable_set('@sid', sid) return [sid, session] ensure @mutex.unlock if env['rack.multithread'] end def set_session(env, sid, new_session, options) @mutex.lock if env['rack.multithread'] expires = Time.now + options[:expire_after] if !options[:expire_after].nil? session = find_session(sid) || {} if options[:renew] or options[:drop] delete_session(sid) return false if options[:drop] sid = generate_sid save_session(sid, session, expires) end old_session = new_session.instance_variable_get('@old') || {} session = merge_sessions(sid, old_session, new_session, session) save_session(sid, session, expires) return sid ensure @mutex.unlock if env['rack.multithread'] end private def generate_sid loop do sid = super break sid unless find_session(sid) end end def find_session(sid) time = Time.now if @recheck_expire_period != -1 && (@next_expire_period.nil? || @next_expire_period < time) @next_expire_period = time + @recheck_expire_period @pool.remove :expires => {'$lte' => time} # clean out expired sessions end session = @pool.find_one :sid => sid #if session is expired but hasn't been cleared yet. don't return it. if session && session['expires'] != nil && session['expires'] < time session = nil end session ? unpack(session['data']) : false end def delete_session(sid) @pool.remove :sid => sid end def save_session(sid, session={}, expires=nil) @pool.update({:sid => sid}, {"$set" => {:data => pack(session), :expires => expires}}, :upsert => true) end def merge_sessions(sid, old, new, current=nil) current ||= {} unless Hash === old and Hash === new warn 'Bad old or new sessions provided.' return current end delete = old.keys - new.keys warn "//@#{sid}: dropping #{delete*','}" if $DEBUG and not delete.empty? delete.each{|k| current.delete k } update = new.keys.select{|k| new[k] != old[k] || new[k].kind_of?(Hash) || new[k].kind_of?(Array) } warn "//@#{sid}: updating #{update*','}" if $DEBUG and not update.empty? update.each{|k| current[k] = new[k] } current end def pack(data) if(@marshal_data) [Marshal.dump(data)].pack("m*") else data end end def unpack(packed) return nil unless packed if(@marshal_data) Marshal.load(packed.unpack("m*").first) else packed end end end end end