lib/action_controller_tweaks/session.rb
# `require "active_support"` required for rails 7
# See more in
# - https://github.com/rails/rails/issues/43851
# - https://github.com/alphagov/govspeak/commit/fa9805297a07e1bdd90a1b47814980fe52ae55ec
require "active_support"
require "active_support/concern"
require "active_support/time"
module ActionControllerTweaks
module Session
extend ActiveSupport::Concern
module Errors
InvalidOptionKeys = Class.new(ArgumentError)
ReservedSessionKeyConflict = Class.new(ArgumentError)
end
module OptionProcessor
def self.extract_expires_in(options)
expires_in = options.delete(:expires_in) || options.delete(:expire_in)
if expires_in && !expires_in.is_a?(Numeric)
fail InvalidOptionValue.new(:expires_in, expires_in, Numeric)
end
expires_in
end
def self.extract_expires_at(options)
expires_at = options.delete(:expires_at) || options.delete(:expire_at)
if expires_at && expires_at.respond_to?(:to_time)
expires_at = expires_at.to_time
end
if expires_at && !expires_at.is_a?(Time)
fail InvalidOptionValue.new(:expires_at, expires_at, Time)
end
expires_at
end
end
RESERVED_SESSION_KEYS = %w( session_keys_to_expire )
VALID_OPTION_KEYS = [
:expires_in,
:expires_at,
:expire_in,
:expire_at,
].freeze
class InvalidOptionValue < ArgumentError
def self.new(option_key, options_value, expected_types)
super("option key `#{option_key}` should contain value with type(s): #{expected_types}, "\
"but got <#{options_value.inspect}> (#{options_value.class})")
end
end
included do
# Rails 4+
if respond_to?(:before_action)
before_action :_delete_expired_session_keys
else
fail(
NotImplementedError,
"There is no `.before_action` in this class",
)
end
private
# Set session just like `session[key] = value` but accept some options about expiry
#
# @option expires_in [Integer]
# How long from now should the session value be expired
# @option expire_in [Integer]
# same as `expires_in`
# @option expires_at [Integer]
# What time should the session value be expired
# (using a time in the past would expire at next request)
# @option expire_at [Integer]
# same as `expires_at`
def set_session(key, value, options = {})
if RESERVED_SESSION_KEYS.include?(key.to_s)
fail Errors::ReservedSessionKeyConflict.new,
"you are trying to set #{value} to #{key}, "\
"but reserved by ActionControllerTweaks::Session"
end
session[key] = value
session[:session_keys_to_expire] = _new_session_keys_to_expire(key, options)
end
# set value in session just like `set_session`, but checked option keys
#
# @raise [ActionControllerTweaks::Session::Errors::InvalidOptionKeys]
def set_session_with_expiry(key, value, options = {})
option_keys = options.symbolize_keys.keys
required_option_key_present = option_keys.any? do |k|
VALID_OPTION_KEYS.include?(k)
end
invalid_option_key_absent = (option_keys - VALID_OPTION_KEYS.dup).empty?
unless required_option_key_present && invalid_option_key_absent
fail ActionControllerTweaks::Session::Errors::InvalidOptionKeys
end
set_session(key, value, options)
end
def _delete_expired_session_keys
# Remove keys that are expired
session_keys_to_expire.each do |key, expire_at_str|
_delete_expired_session_key(key, expire_at_str)
end
end
def _delete_expired_session_key(key, expire_at_str)
if Time.now > Time.parse(expire_at_str.to_s)
session.delete(key)
session_keys_to_expire.delete(key)
end
rescue
# Parse error
# Let"s expire it to be safe
session.delete(key)
session_keys_to_expire.delete(key)
end
def session_keys_to_expire
# Check whether session key is a hash to prevent exception
unless session[:session_keys_to_expire].is_a?(Hash)
session[:session_keys_to_expire] = {}
end
session[:session_keys_to_expire]
end
def _new_session_keys_to_expire(key, options = {})
options.symbolize_keys!
result = session_keys_to_expire
expires_in = OptionProcessor.extract_expires_in(options)
expires_at = OptionProcessor.extract_expires_at(options)
if [expires_in, expires_at].any?
result[key] = expires_in ? expires_in.seconds.from_now : expires_at
end
result
end
end
end
end