|
1 |
| -begin |
2 |
| - |
3 |
| -require 'active_record' |
4 | 1 | require 'cgi'
|
5 | 2 | require 'cgi/session'
|
| 3 | +require 'digest/md5' |
6 | 4 | require 'base64'
|
7 | 5 |
|
8 |
| -# Contributed by Tim Bates |
9 | 6 | class CGI
|
10 | 7 | class Session
|
11 |
| - # Active Record database-based session storage class. |
| 8 | + # A session store backed by an Active Record class. |
| 9 | + # |
| 10 | + # A default class is provided, but any object duck-typing to an Active |
| 11 | + # Record +Session+ class with text +session_id+ and +data+ attributes |
| 12 | + # may be used as the backing store. |
12 | 13 | #
|
13 |
| - # Implements session storage in a database using the ActiveRecord ORM library. Assumes that the database |
14 |
| - # has a table called +sessions+ with columns +id+ (numeric, primary key), +sessid+ and +data+ (text). |
15 |
| - # The session data is stored in the +data+ column in the binary Marshal format; the user is responsible for ensuring that |
16 |
| - # only data that can be Marshaled is stored in the session. |
| 14 | + # The default assumes a +sessions+ tables with columns +id+ (numeric |
| 15 | + # primary key), +session_id+ (text), and +data+ (text). Session data is |
| 16 | + # marshaled to +data+. +session_id+ should be indexed for speedy lookups. |
17 | 17 | #
|
18 |
| - # Adding +created_at+ or +updated_at+ datetime columns to the sessions table will enable stamping of the data, which can |
19 |
| - # be used to clear out old sessions. |
| 18 | + # Since the default class is a simple Active Record, you get timestamps |
| 19 | + # for free if you add +created_at+ and +updated_at+ datetime columns to |
| 20 | + # the +sessions+ table, making periodic session expiration a snap. |
20 | 21 | #
|
21 |
| - # It's highly recommended to have an index on the sessid column to improve performance. |
| 22 | + # You may provide your own session class, whether a feature-packed |
| 23 | + # Active Record or a bare-metal high-performance SQL store, by setting |
| 24 | + # +CGI::Session::ActiveRecordStore.session_class = MySessionClass+ |
| 25 | + # You must implement these methods: |
| 26 | + # self.find_by_session_id(session_id) |
| 27 | + # initialize(hash_of_session_id_and_data) |
| 28 | + # attr_reader :session_id |
| 29 | + # attr_accessor :data |
| 30 | + # save! |
| 31 | + # destroy |
| 32 | + # |
| 33 | + # The fast SqlBypass class is a generic SQL session store. You may |
| 34 | + # use it as a basis for high-performance database-specific stores. |
22 | 35 | class ActiveRecordStore
|
23 |
| - # The ActiveRecord class which corresponds to the database table. |
| 36 | + # The default Active Record class. |
24 | 37 | class Session < ActiveRecord::Base
|
| 38 | + self.table_name = 'sessions' |
| 39 | + before_create :marshal_data! |
| 40 | + before_update :marshal_data_if_changed! |
| 41 | + after_save :clear_data_cache! |
| 42 | + |
| 43 | + class << self |
| 44 | + # Hook to set up sessid compatibility. |
| 45 | + def find_by_session_id(session_id) |
| 46 | + setup_sessid_compatibility! |
| 47 | + find_by_session_id(session_id) |
| 48 | + end |
| 49 | + |
| 50 | + # Compatibility with tables using sessid instead of session_id. |
| 51 | + def setup_sessid_compatibility! |
| 52 | + if !@sessid_compatibility_checked |
| 53 | + if columns_hash['sessid'] |
| 54 | + def self.find_by_session_id(*args) |
| 55 | + find_by_sessid(*args) |
| 56 | + end |
| 57 | + |
| 58 | + alias_method :session_id, :sessid |
| 59 | + define_method(:session_id) { sessid } |
| 60 | + define_method(:session_id=) { |session_id| self.sessid = session_id } |
| 61 | + else |
| 62 | + def self.find_by_session_id(session_id) |
| 63 | + find :first, :conditions => ["session_id #{attribute_condition(session_id)}", session_id] |
| 64 | + end |
| 65 | + end |
| 66 | + @sessid_compatibility_checked = true |
| 67 | + end |
| 68 | + end |
| 69 | + |
| 70 | + def marshal(data) Base64.encode64(Marshal.dump(data)) end |
| 71 | + def unmarshal(data) Marshal.load(Base64.decode64(data)) end |
| 72 | + def fingerprint(data) Digest::MD5.hexdigest(data) end |
| 73 | + |
| 74 | + def create_table! |
| 75 | + connection.execute <<-end_sql |
| 76 | + CREATE TABLE #{table_name} ( |
| 77 | + id INTEGER PRIMARY KEY, |
| 78 | + #{connection.quote_column_name('session_id')} TEXT UNIQUE, |
| 79 | + #{connection.quote_column_name('data')} TEXT |
| 80 | + ) |
| 81 | + end_sql |
| 82 | + end |
| 83 | + |
| 84 | + def drop_table! |
| 85 | + connection.execute "DROP TABLE #{table_name}" |
| 86 | + end |
| 87 | + end |
| 88 | + |
| 89 | + # Lazy-unmarshal session state. |
| 90 | + def data |
| 91 | + unless @data |
| 92 | + @data = self.class.unmarshal(read_attribute('data')) |
| 93 | + @fingerprint = self.class.fingerprint(@data) |
| 94 | + end |
| 95 | + @data |
| 96 | + end |
| 97 | + |
| 98 | + private |
| 99 | + def marshal_data! |
| 100 | + write_attribute('data', self.class.marshal(@data || {})) |
| 101 | + end |
| 102 | + |
| 103 | + def marshal_data_if_changed! |
| 104 | + if @data and @fingerprint != self.class.fingerprint(@data) |
| 105 | + marshal_data! |
| 106 | + end |
| 107 | + end |
| 108 | + |
| 109 | + def clear_data_cache! |
| 110 | + @data = @fingerprint = nil |
| 111 | + end |
25 | 112 | end
|
26 | 113 |
|
27 |
| - # Create a new ActiveRecordStore instance. This constructor is used internally by CGI::Session. |
28 |
| - # The user does not generally need to call it directly. |
29 |
| - # |
30 |
| - # +session+ is the session for which this instance is being created. |
| 114 | + # A barebones session store which duck-types with the default session |
| 115 | + # store but bypasses Active Record and issues SQL directly. |
31 | 116 | #
|
32 |
| - # +option+ is currently ignored as no options are recognized. |
| 117 | + # The database connection, table name, and session id and data columns |
| 118 | + # are configurable class attributes. Marshaling and unmarshaling |
| 119 | + # are implemented as class methods that you may override. By default, |
| 120 | + # marshaling data is +Base64.encode64(Marshal.dump(data))+ and |
| 121 | + # unmarshaling data is +Marshal.load(Base64.decode64(data))+. |
33 | 122 | #
|
34 |
| - # This session's ActiveRecord database row will be created if it does not exist, or opened if it does. |
35 |
| - def initialize(session, option=nil) |
36 |
| - ActiveRecord::Base.silence do |
37 |
| - @session = Session.find_by_sessid(session.session_id) || Session.new("sessid" => session.session_id, "data" => marshalize({})) |
38 |
| - @data = unmarshalize(@session.data) |
| 123 | + # This marshaling behavior is intended to store the widest range of |
| 124 | + # binary session data in a +text+ column. For higher performance, |
| 125 | + # store in a +blob+ column instead and forgo the Base64 encoding. |
| 126 | + class SqlBypass |
| 127 | + # Use the ActiveRecord::Base.connection by default. |
| 128 | + cattr_accessor :connection |
| 129 | + def self.connection |
| 130 | + @@connection ||= ActiveRecord::Base.connection |
39 | 131 | end
|
40 |
| - end |
41 | 132 |
|
42 |
| - # Update and close the session's ActiveRecord object. |
43 |
| - def close |
44 |
| - return unless @session |
45 |
| - update |
46 |
| - @session = nil |
| 133 | + # The table name defaults to 'sessions'. |
| 134 | + cattr_accessor :table_name |
| 135 | + @@table_name = 'sessions' |
| 136 | + |
| 137 | + # The session id field defaults to 'session_id'. |
| 138 | + cattr_accessor :session_id_column |
| 139 | + @@session_id_column = 'session_id' |
| 140 | + |
| 141 | + # The data field defaults to 'data'. |
| 142 | + cattr_accessor :data_column |
| 143 | + @@data_column = 'data' |
| 144 | + |
| 145 | + class << self |
| 146 | + # Look up a session by id and unmarshal its data if found. |
| 147 | + def find_by_session_id(session_id) |
| 148 | + if record = @@connection.select_one("SELECT * FROM #{@@table_name} WHERE #{@@session_id_column}=#{@@connection.quote(session_id)}") |
| 149 | + new(:session_id => session_id, :marshaled_data => record['data']) |
| 150 | + end |
| 151 | + end |
| 152 | + |
| 153 | + def marshal(data) Base64.encode64(Marshal.dump(data)) end |
| 154 | + def unmarshal(data) Marshal.load(Base64.decode64(data)) end |
| 155 | + def fingerprint(data) Digest::MD5.hexdigest(data) end |
| 156 | + |
| 157 | + def create_table! |
| 158 | + @@connection.execute <<-end_sql |
| 159 | + CREATE TABLE #{table_name} ( |
| 160 | + #{@@connection.quote_column_name(session_id_column)} TEXT PRIMARY KEY, |
| 161 | + #{@@connection.quote_column_name(data_column)} TEXT |
| 162 | + ) |
| 163 | + end_sql |
| 164 | + end |
| 165 | + |
| 166 | + def drop_table! |
| 167 | + @@connection.execute "DROP TABLE #{table_name}" |
| 168 | + end |
| 169 | + end |
| 170 | + |
| 171 | + attr_reader :session_id |
| 172 | + attr_writer :data |
| 173 | + |
| 174 | + # Look for normal and marshaled data, self.find_by_session_id's way of |
| 175 | + # telling us to postpone unmarshaling until the data is requested. |
| 176 | + # We need to handle a normal data attribute in case of a new record. |
| 177 | + def initialize(attributes) |
| 178 | + @session_id, @data, @marshaled_data = attributes[:session_id], attributes[:data], attributes[:marshaled_data] |
| 179 | + @new_record = !@marshaled_data.nil? |
| 180 | + end |
| 181 | + |
| 182 | + # Lazy-unmarshal session state. Take a fingerprint so we can detect |
| 183 | + # whether to save changes later. |
| 184 | + def data |
| 185 | + if @marshaled_data |
| 186 | + @data, @marshaled_data = self.class.unmarshal(@marshaled_data), nil |
| 187 | + @fingerprint = self.class.fingerprint(@data) |
| 188 | + end |
| 189 | + @data |
| 190 | + end |
| 191 | + |
| 192 | + def save! |
| 193 | + if @new_record |
| 194 | + @new_record = false |
| 195 | + @@connection.update <<-end_sql, 'Create session' |
| 196 | + INSERT INTO #{@@table_name} ( |
| 197 | + #{@@connection.quote_column_name(@@session_id_column)}, |
| 198 | + #{@@connection.quote_column_name(@@data_column)} ) |
| 199 | + VALUES ( |
| 200 | + #{@@connection.quote(session_id)}, |
| 201 | + #{@@connection.quote(self.class.marshal(data))} ) |
| 202 | + end_sql |
| 203 | + elsif self.class.fingerprint(data) != @fingerprint |
| 204 | + @@connection.update <<-end_sql, 'Update session' |
| 205 | + UPDATE #{@@table_name} |
| 206 | + SET #{@@connection.quote_column_name(@@data_column)}=#{@@connection.quote(self.class.marshal(data))} |
| 207 | + WHERE #{@@connection.quote_column_name(@@session_id_column)}=#{@@connection.quote(session_id)} |
| 208 | + end_sql |
| 209 | + end |
| 210 | + end |
| 211 | + |
| 212 | + def destroy |
| 213 | + unless @new_record |
| 214 | + @@connection.delete <<-end_sql, 'Destroy session' |
| 215 | + DELETE FROM #{@@table_name} |
| 216 | + WHERE #{@@connection.quote_column_name(@@session_id_column)}=#{@@connection.quote(session_id)} |
| 217 | + end_sql |
| 218 | + end |
| 219 | + end |
47 | 220 | end
|
48 | 221 |
|
49 |
| - # Close and destroy the session's ActiveRecord object. |
50 |
| - def delete |
51 |
| - return unless @session |
52 |
| - @session.destroy |
53 |
| - @session = nil |
| 222 | + # The class used for session storage. Defaults to |
| 223 | + # CGI::Session::ActiveRecordStore::Session. |
| 224 | + cattr_accessor :session_class |
| 225 | + @@session_class = Session |
| 226 | + |
| 227 | + # Find or instantiate a session given a CGI::Session. |
| 228 | + def initialize(session, option = nil) |
| 229 | + session_id = session.session_id |
| 230 | + unless @session = @@session_class.find_by_session_id(session_id) |
| 231 | + unless session.new_session |
| 232 | + raise CGI::Session::NoSession, 'uninitialized session' |
| 233 | + end |
| 234 | + @session = @@session_class.new(:session_id => session_id, :data => {}) |
| 235 | + end |
54 | 236 | end
|
55 | 237 |
|
56 |
| - # Restore session state from the session's ActiveRecord object. |
| 238 | + # Restore session state. The session model handles unmarshaling. |
57 | 239 | def restore
|
58 |
| - return unless @session |
59 |
| - @data = unmarshalize(@session.data) |
| 240 | + @session.data |
60 | 241 | end
|
61 | 242 |
|
62 |
| - # Save session state in the session's ActiveRecord object. |
| 243 | + # Save session store. |
63 | 244 | def update
|
64 |
| - return unless @session |
65 |
| - ActiveRecord::Base.silence { @session.update_attribute "data", marshalize(@data) } |
| 245 | + @session.save! |
66 | 246 | end
|
67 | 247 |
|
68 |
| - private |
69 |
| - def unmarshalize(data) |
70 |
| - Marshal.load(Base64.decode64(data)) |
71 |
| - end |
| 248 | + # Save and close the session store. |
| 249 | + def close |
| 250 | + update |
| 251 | + @session = nil |
| 252 | + end |
72 | 253 |
|
73 |
| - def marshalize(data) |
74 |
| - Base64.encode64(Marshal.dump(data)) |
75 |
| - end |
76 |
| - end #ActiveRecordStore |
77 |
| - end #Session |
78 |
| -end #CGI |
| 254 | + # Delete and close the session store. |
| 255 | + def delete |
| 256 | + @session.destroy rescue nil |
| 257 | + @session = nil |
| 258 | + end |
| 259 | + end |
79 | 260 |
|
80 |
| -rescue LoadError |
81 |
| - # Couldn't load Active Record, so don't make this store available |
| 261 | + end |
82 | 262 | end
|
0 commit comments