redis-client-session-store 0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis-client'
4
+
5
+ # Redis session storage for Rails, and for Rails only. Derived from
6
+ # the MemCacheStore code, simply dropping in Redis instead.
7
+ class RedisSessionStore < ActionDispatch::Session::AbstractSecureStore
8
+ VERSION = '0.12'
9
+ # Rails 3.1 and beyond defines the constant elsewhere
10
+ unless defined?(ENV_SESSION_OPTIONS_KEY)
11
+ ENV_SESSION_OPTIONS_KEY = if Rack.release.split('.').first.to_i > 1
12
+ Rack::RACK_SESSION_OPTIONS
13
+ else
14
+ Rack::Session::Abstract::ENV_SESSION_OPTIONS_KEY
15
+ end
16
+ end
17
+
18
+ USE_INDIFFERENT_ACCESS = defined?(ActiveSupport).freeze
19
+ # ==== Options
20
+ # * +:key+ - Same as with the other cookie stores, key name
21
+ # * +:redis+ - A hash with redis-specific options
22
+ # * +:url+ - Redis url, default is redis://localhost:6379/0
23
+ # * +:key_prefix+ - Prefix for keys used in Redis, e.g. +myapp:+
24
+ # * +:expire_after+ - A number in seconds for session timeout
25
+ # * +:client+ - Connect to Redis with given object rather than create one
26
+ # * +:on_redis_down:+ - Called with err, env, and SID on Errno::ECONNREFUSED
27
+ # * +:on_session_load_error:+ - Called with err and SID on Marshal.load fail
28
+ # * +:serializer:+ - Serializer to use on session data, default is :marshal.
29
+ #
30
+ # ==== Examples
31
+ #
32
+ # Rails.application.config.session_store :redis_client_session_store,
33
+ # key: 'your_session_key',
34
+ # redis: {
35
+ # expire_after: 120.minutes,
36
+ # key_prefix: 'myapp:session:',
37
+ # url: 'redis://localhost:6379/0'
38
+ # },
39
+ # on_redis_down: ->(*a) { logger.error("Redis down! #{a.inspect}") },
40
+ # serializer: :hybrid # migrate from Marshal to JSON
41
+ #
42
+ def initialize(app, options = {})
43
+ super
44
+
45
+ @default_options[:namespace] = 'rack:session'
46
+ @default_options.merge!(options[:redis] || {})
47
+ init_options = options[:redis]&.reject do |k, _v|
48
+ %i[expire_after key_prefix ttl].include?(k)
49
+ end || {}
50
+ @redis = init_options[:client] || RedisClient.new(init_options)
51
+ @on_redis_down = options[:on_redis_down]
52
+ @serializer = determine_serializer(options[:serializer])
53
+ @on_session_load_error = options[:on_session_load_error]
54
+ verify_handlers!
55
+ end
56
+
57
+ attr_accessor :on_redis_down, :on_session_load_error
58
+
59
+ private
60
+
61
+ attr_reader :redis, :key, :default_options, :serializer
62
+
63
+ # overrides method defined in rack to actually verify session existence
64
+ # Prevents needless new sessions from being created in scenario where
65
+ # user HAS session id, but it already expired, or is invalid for some
66
+ # other reason, and session was accessed only for reading.
67
+ def session_exists?(env)
68
+ value = current_session_id(env)
69
+
70
+ !!(
71
+ value && !value.empty? &&
72
+ key_exists_with_fallback?(value)
73
+ )
74
+ rescue Errno::ECONNREFUSED, RedisClient::CannotConnectError => e
75
+ on_redis_down.call(e, env, value) if on_redis_down
76
+
77
+ true
78
+ end
79
+
80
+ def key_exists_with_fallback?(value)
81
+ return false if private_session_id?(value.public_id)
82
+
83
+ key_exists?(value.private_id) || key_exists?(value.public_id)
84
+ end
85
+
86
+ def key_exists?(value)
87
+ redis.call('EXISTS', prefixed(value)).positive?
88
+ end
89
+
90
+ def private_session_id?(value)
91
+ value.match?(/\A\d+::/)
92
+ end
93
+
94
+ def verify_handlers!
95
+ %w(on_redis_down on_session_load_error).each do |h|
96
+ next unless (handler = public_send(h)) && !handler.respond_to?(:call)
97
+
98
+ raise ArgumentError, "#{h} handler is not callable"
99
+ end
100
+ end
101
+
102
+ def prefixed(sid)
103
+ "#{default_options[:key_prefix]}#{sid}"
104
+ end
105
+
106
+ def session_default_values
107
+ [generate_sid, USE_INDIFFERENT_ACCESS ? {}.with_indifferent_access : {}]
108
+ end
109
+
110
+ def get_session(env, sid)
111
+ sid && (session = load_session_with_fallback(sid)) ? [sid, session] : session_default_values
112
+ rescue Errno::ECONNREFUSED, RedisClient::CannotConnectError => e
113
+ on_redis_down.call(e, env, sid) if on_redis_down
114
+ session_default_values
115
+ end
116
+ alias find_session get_session
117
+
118
+ def load_session_with_fallback(sid)
119
+ return nil if private_session_id?(sid.public_id)
120
+
121
+ load_session_from_redis(
122
+ key_exists?(sid.private_id) ? sid.private_id : sid.public_id
123
+ )
124
+ end
125
+
126
+ def load_session_from_redis(sid)
127
+ data = redis.call('GET', prefixed(sid))
128
+ begin
129
+ data ? decode(data) : nil
130
+ rescue StandardError => e
131
+ destroy_session_from_sid(sid, drop: true)
132
+ on_session_load_error.call(e, sid) if on_session_load_error
133
+ nil
134
+ end
135
+ end
136
+
137
+ def decode(data)
138
+ session = serializer.load(data)
139
+ USE_INDIFFERENT_ACCESS ? session.with_indifferent_access : session
140
+ end
141
+
142
+ def set_session(env, sid, session_data, options = nil)
143
+ expiry = get_expiry(env, options)
144
+ if expiry
145
+ redis.call('SETEX', prefixed(sid.private_id), expiry, encode(session_data))
146
+ else
147
+ redis.call('SET', prefixed(sid.private_id), encode(session_data))
148
+ end
149
+ sid
150
+ rescue Errno::ECONNREFUSED, RedisClient::CannotConnectError => e
151
+ on_redis_down.call(e, env, sid) if on_redis_down
152
+ false
153
+ end
154
+ alias write_session set_session
155
+
156
+ def get_expiry(env, options)
157
+ session_storage_options = options || env.fetch(ENV_SESSION_OPTIONS_KEY, {})
158
+ session_storage_options[:ttl] || session_storage_options[:expire_after]
159
+ end
160
+
161
+ def encode(session_data)
162
+ serializer.dump(session_data)
163
+ end
164
+
165
+ def destroy_session(env, sid, options)
166
+ destroy_session_from_sid(sid.public_id, (options || {}).to_hash.merge(env:, drop: true))
167
+ destroy_session_from_sid(sid.private_id, (options || {}).to_hash.merge(env:))
168
+ end
169
+ alias delete_session destroy_session
170
+
171
+ def destroy(env)
172
+ if env['rack.request.cookie_hash'] &&
173
+ (sid = env['rack.request.cookie_hash'][key])
174
+ sid = Rack::Session::SessionId.new(sid)
175
+ destroy_session_from_sid(sid.private_id, drop: true, env:)
176
+ destroy_session_from_sid(sid.public_id, drop: true, env:)
177
+ end
178
+ false
179
+ end
180
+
181
+ def destroy_session_from_sid(sid, options = {})
182
+ redis.call('DEL', prefixed(sid))
183
+ (options || {})[:drop] ? nil : generate_sid
184
+ rescue Errno::ECONNREFUSED, RedisClient::CannotConnectError => e
185
+ on_redis_down.call(e, options[:env] || {}, sid) if on_redis_down
186
+ end
187
+
188
+ def determine_serializer(serializer)
189
+ serializer ||= :marshal
190
+ case serializer
191
+ when :marshal then Marshal
192
+ when :json then JsonSerializer
193
+ when :hybrid then HybridSerializer
194
+ else serializer
195
+ end
196
+ end
197
+
198
+ # Uses built-in JSON library to encode/decode session
199
+ class JsonSerializer
200
+ def self.load(value)
201
+ JSON.parse(value, quirks_mode: true)
202
+ end
203
+
204
+ def self.dump(value)
205
+ JSON.generate(value, quirks_mode: true)
206
+ end
207
+ end
208
+
209
+ # Transparently migrates existing session values from Marshal to JSON
210
+ class HybridSerializer < JsonSerializer
211
+ MARSHAL_SIGNATURE = "\x04\x08"
212
+
213
+ def self.load(value)
214
+ if needs_migration?(value)
215
+ Marshal.load(value)
216
+ else
217
+ super
218
+ end
219
+ end
220
+
221
+ def self.needs_migration?(value)
222
+ value.start_with?(MARSHAL_SIGNATURE)
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,26 @@
1
+ Gem::Specification.new do |gem|
2
+ gem.name = 'redis-client-session-store'
3
+ gem.authors = ['Andrey "Zed" Zaikin']
4
+ gem.email = ['zed.0xff@gmail.com']
5
+ gem.summary = 'Rails session store using low-level redis-client for Redis 6+'
6
+ gem.description = "#{gem.summary} For great glory!"
7
+ gem.homepage = 'https://github.com/zed-0xff/redis-client-session-store'
8
+ gem.license = 'MIT'
9
+
10
+ gem.extra_rdoc_files = %w(LICENSE AUTHORS.md CONTRIBUTING.md)
11
+
12
+ gem.files = `git ls-files -z`.split("\x0")
13
+ gem.require_paths = %w(lib)
14
+ gem.version = File.read('lib/redis-client-session-store.rb')
15
+ .match(/^ VERSION = '(.*)'/)[1]
16
+
17
+ gem.add_runtime_dependency 'actionpack', '>= 5.2.4.1', '< 8'
18
+ gem.add_runtime_dependency 'redis-client'
19
+
20
+ gem.add_development_dependency 'rake', '~> 13'
21
+ gem.add_development_dependency 'rspec', '~> 3'
22
+ gem.add_development_dependency 'rubocop', '~> 1.25'
23
+ gem.add_development_dependency 'rubocop-rake', '~> 0.6'
24
+ gem.add_development_dependency 'rubocop-rspec', '~> 2.8'
25
+ gem.add_development_dependency 'simplecov', '~> 0.21'
26
+ end