Skip to content

Commit

Permalink
Using ActionCable to poll the backend via websockets to determine use…
Browse files Browse the repository at this point in the history
…r activity and extend or cancel the session
  • Loading branch information
GeorgeCodes19 committed Feb 26, 2025
1 parent 836acaa commit c077628
Show file tree
Hide file tree
Showing 9 changed files with 313 additions and 233 deletions.
16 changes: 2 additions & 14 deletions app/app/channels/application_cable/connection.rb
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user, :session_id
identified_by :session_id

def connect
self.current_user = find_verified_user
self.session_id = session.id
logger.add_tags "ActionCable", current_user&.id || "Guest-#{session_id}"
end
self.current_user = find_verified_user

def session
@request.session
end

private

def find_verified_user
if verified_user = env['warden'].user
verified_user
else
nil
end
end
end
end
197 changes: 139 additions & 58 deletions app/app/channels/session_channel.rb
Original file line number Diff line number Diff line change
@@ -1,88 +1,169 @@
class SessionChannel < ApplicationCable::Channel
# Check session activity every minute
periodically :check_session_activity, every: 60.seconds
# Check session activity every minute in production, more frequently in development
periodically :check_session_activity, every: Rails.env.development? ? 5.seconds : 60.seconds

def subscribed
identifier = current_user || connection.session[:cbv_flow_id] || connection.session.id
stream_for identifier
@cbv_flow_id = connection.session[:cbv_flow_id]
stream_for current_channel_identifier

# Record connection time
connection.instance_variable_set(:@session_channel_connected_at, Time.current)

# Send session info to client

# Track this initial connection as an activity
update_last_activity_time

# Send initial session info
transmit_session_info
end

def received(data)
# This is called whenever data is received from the client
# We can use this to track user activity at the websocket level
Rails.logger.debug "Data received on SessionChannel: #{data}"
# This method is called for general messages received from the client
# that don't match a specific method name in perform('method_name').
# ActionCable automatically routes messages to methods named in 'perform'.
# Example: Client calls subscription.perform('extend_session') will directly
# call the extend_session method, bypassing this received method.

Rails.logger.debug "Data received on SessionChannel: #{data}"

# Update last activity time
connection.instance_variable_set(:@session_channel_last_activity, Time.current)
update_last_activity_time
end

# Send current session information to the client
def transmit_session_info
identifier = current_user || connection.session[:cbv_flow_id] || connection.session.id

if current_user && current_user.respond_to?(:last_request_at) && current_user.last_request_at
# For authenticated users, use Devise timeout (default 30 minutes)
timeout_in = defined?(Devise) && Devise.respond_to?(:timeout_in) ? Devise.timeout_in : 30.minutes
last_active = current_user.last_request_at || Time.current
time_until_timeout = [timeout_in - (Time.current - last_active), 0].max

transmit({
event: "session.info",
timeout_in_ms: timeout_in * 1000,
time_remaining_ms: time_until_timeout * 1000,
last_activity: last_active.iso8601
})
elsif connection.session[:cbv_flow_id].present?
# For CBV flow, use 30 minute timeout
transmit({
event: "session.info",
timeout_in_ms: 30.minutes * 1000,
time_remaining_ms: 30.minutes * 1000,
last_activity: Time.current.iso8601
})
def extend_session(data)
update_last_activity_time

if @cbv_flow_id
connection.session[:last_activity_at] = Time.current.to_i
Rails.logger.debug "Updated session timestamp for CBV flow #{@cbv_flow_id}"
end

# Clear any warning flags since the session has been extended
connection.instance_variable_set(:@warning_sent_at, nil)

Rails.logger.info "Transmitting session.extended event"
transmit({
event: "session.extended",
message: "Session extended successfully",
extended_at: Time.current.iso8601
})

# Also update session info with new timestamps
transmit_session_info

Rails.logger.info "Session successfully extended for #{current_channel_identifier}"
end

def transmit_session_info
# Get the Devise timeout setting
timeout_in = get_timeout_setting

# Calculate time remaining
last_active = get_last_activity_time
time_until_timeout = [ timeout_in - (Time.current - last_active), 0 ].max

transmit({
event: "session.info",
timeout_in_ms: timeout_in * 1000,
time_remaining_ms: time_until_timeout * 1000,
last_activity: last_active.iso8601
})
end

private

def check_session_activity
identifier = current_user || connection.session[:cbv_flow_id] || connection.session.id
return unless identifier

# Get last activity time either from the connection or from the user's last_request_at
last_activity = connection.instance_variable_get(:@session_channel_last_activity)
def current_channel_identifier
current_user&.id || @cbv_flow_id || connection.session.id
end

def get_timeout_setting
# In development, use a shorter timeout for testing (2 minutes)
Rails.env.development? ? 2.minutes : 30.minutes
end

# Update the last activity timestamp
def update_last_activity_time
current_time = Time.current
connection.instance_variable_set(:@session_channel_last_activity, current_time)

if current_user && current_user.respond_to?(:last_request_at) && current_user.last_request_at
# For authenticated users, use Devise timeout (default 30 minutes)
timeout_in = defined?(Devise) && Devise.respond_to?(:timeout_in) ? Devise.timeout_in : 30.minutes
last_active = [last_activity, current_user.last_request_at].compact.max || Time.current
time_until_timeout = [timeout_in - (Time.current - last_active), 0].max
Rails.logger.debug "User #{current_user.id} session expires in #{time_until_timeout} seconds"
else
# For CBV flow, use session timeout (30 minutes)
timeout_in = 30.minutes
connection_time = connection.instance_variable_get(:@session_channel_connected_at) || Time.current
last_active = last_activity || connection_time
time_until_timeout = [timeout_in - (Time.current - last_active), 0].max
# Also update the session timestamp
if connection.session
connection.session[:last_activity_at] = current_time.to_i
end

Rails.logger.debug "Updated activity time for #{current_channel_identifier} to #{current_time}"
end

def get_last_activity_time
# Get timestamps from different sources
websocket_activity = connection.instance_variable_get(:@session_channel_last_activity)
connection_time = connection.instance_variable_get(:@session_channel_connected_at)
# Get activity time from the session (if tracked in ApplicationController)
session_activity = connection.session && connection.session[:last_activity_at].present? ?
Time.at(connection.session[:last_activity_at]) : nil

# Log the available timestamps for debugging
timestamps = {
websocket_activity: websocket_activity,
session_activity: session_activity,
connection_time: connection_time
}
Rails.logger.debug "Available timestamps for #{current_channel_identifier}: #{timestamps.inspect}"

# Use the most recent timestamp from any source, fallback to current time
[ websocket_activity, session_activity, connection_time, Time.current ].compact.max
end

# Convert to milliseconds for JavaScript
def check_session_activity
Rails.logger.debug "SessionChannel#check_session_activity called at #{Time.current}"
return unless @cbv_flow_id

# Get the timeout setting
session_timeout_limit = get_timeout_setting

# Get the most recent activity timestamp
last_active = get_last_activity_time

# Calculate time until timeout
time_until_timeout = [ session_timeout_limit - (Time.current - last_active), 0 ].max
time_until_timeout_ms = time_until_timeout * 1000

# Log detailed information for debugging
Rails.logger.debug "Session for #{current_channel_identifier}: timeout_limit=#{session_timeout_limit}, last_active=#{last_active}, " +
"current=#{Time.current}, time_until_timeout=#{time_until_timeout}s"

# Send warning when approaching timeout (5 minutes before)
warning_threshold = 5.minutes.to_i * 1000
if time_until_timeout_ms <= warning_threshold && time_until_timeout_ms > 0
broadcast_to(identifier, {
warning_already_sent = connection.instance_variable_get(:@warning_sent_at)

# Only send a warning if we're in the warning window AND
# (we haven't sent a warning yet OR the previous warning was sent more than 1 minute ago)
if time_until_timeout_ms <= warning_threshold && time_until_timeout_ms > 0 &&
(warning_already_sent.nil? || Time.current - warning_already_sent > 1.minute)

# Record that we sent a warning
connection.instance_variable_set(:@warning_sent_at, Time.current)

Rails.logger.info "Sending session timeout warning to #{current_channel_identifier}, #{time_until_timeout_ms / 1000} seconds remaining"

broadcast_to(current_channel_identifier, {
event: "session.warning",
message: "Your session will expire soon",
time_remaining_ms: time_until_timeout_ms
})
end

# Debug info in development
if Rails.env.development?
broadcast_to(current_channel_identifier, {
event: "session.debug",
message: "Session activity check ran",
time_remaining_ms: time_until_timeout_ms,
last_activity: last_active.iso8601,
timeout_in: session_timeout_limit.to_i,
current_time: Time.current.iso8601,
cbv_flow_id: @cbv_flow_id,
warning_sent_at: connection.instance_variable_get(:@warning_sent_at)&.iso8601
})
end
end
end
end
76 changes: 0 additions & 76 deletions app/app/controllers/api/sessions_controller.rb

This file was deleted.

5 changes: 5 additions & 0 deletions app/app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class ApplicationController < ActionController::Base
before_action :redirect_if_maintenance_mode
before_action :enable_mini_profiler_in_demo
before_action :check_help_param
before_action :track_user_activity

rescue_from ActionController::InvalidAuthenticityToken do
redirect_to root_url, flash: { slim_alert: { type: "info", message_html: t("cbv.error_missing_token_html") } }
Expand Down Expand Up @@ -97,4 +98,8 @@ def check_help_param
flash.now[:alert_type] = "warning"
end
end

def track_user_activity
session[:last_activity_at] = Time.current.to_i
end
end
Loading

0 comments on commit c077628

Please sign in to comment.