# frozen_string_literal: true # The server-side implementation of PresenceChannels. See also {PresenceController} # and +app/assets/javascripts/discourse/app/services/presence.js+ class PresenceChannel class NotFound < StandardError end class InvalidAccess < StandardError end class ConfigNotLoaded < StandardError end class InvalidConfig < StandardError end class State include ActiveModel::Serialization attr_reader :message_bus_last_id attr_reader :user_ids attr_reader :count def initialize(message_bus_last_id:, user_ids: nil, count: nil) raise "user_ids or count required" if user_ids.nil? && count.nil? @message_bus_last_id = message_bus_last_id @user_ids = user_ids @count = count || user_ids.count end def users return nil if user_ids.nil? User.where(id: user_ids) end end # Class for managing config of PresenceChannel # Three parameters can be provided on initialization: # public: boolean value. If true, channel information is visible to all users (default false) # allowed_user_ids: array of user_ids that can view, and become present in, the channel (default []) # allowed_group_ids: array of group_ids that can view, and become present in, the channel (default []) # count_only: boolean. If true, user identities are never revealed to clients. (default []) class Config NOT_FOUND ||= "notfound" attr_accessor :public, :allowed_user_ids, :allowed_group_ids, :count_only, :timeout def initialize( public: false, allowed_user_ids: nil, allowed_group_ids: nil, count_only: false, timeout: nil ) @public = public @allowed_user_ids = allowed_user_ids @allowed_group_ids = allowed_group_ids @count_only = count_only @timeout = timeout end def self.from_json(json) data = JSON.parse(json, symbolize_names: true) data = {} if !data.is_a? Hash new(**data.slice(:public, :allowed_user_ids, :allowed_group_ids, :count_only, :timeout)) end def to_json data = { public: public } data[:allowed_user_ids] = allowed_user_ids if allowed_user_ids data[:allowed_group_ids] = allowed_group_ids if allowed_group_ids data[:count_only] = count_only if count_only data[:timeout] = timeout if timeout data.to_json end end DEFAULT_TIMEOUT ||= 60 CONFIG_CACHE_SECONDS ||= 10 GC_SECONDS ||= 24.hours.to_i MUTEX_TIMEOUT_SECONDS ||= 10 MUTEX_LOCKED_ERROR ||= "PresenceChannel mutex is locked" @@configuration_blocks ||= {} attr_reader :name, :timeout, :message_bus_channel_name, :config def initialize(name, raise_not_found: true, use_cache: true) @name = name @message_bus_channel_name = "/presence#{name}" begin @config = fetch_config(use_cache: use_cache) rescue PresenceChannel::NotFound raise if raise_not_found @config = Config.new end @timeout = config.timeout || DEFAULT_TIMEOUT end # Is this user allowed to view this channel? # Pass `nil` for anonymous viewers def can_view?(user_id: nil, group_ids: nil) return true if config.public return true if user_id && config.allowed_user_ids&.include?(user_id) if user_id && config.allowed_group_ids.present? return true if config.allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone]) group_ids ||= GroupUser.where(user_id: user_id).pluck("group_id") return true if (group_ids & config.allowed_group_ids).present? end false end # Is a user allowed to enter this channel? # Currently equal to the can_view? permission def can_enter?(user_id: nil, group_ids: nil) return false if user_id.nil? can_view?(user_id: user_id, group_ids: group_ids) end # Mark a user's client as present in this channel. The client_id should be unique per # browser tab. This method should be called repeatedly (at least once every DEFAULT_TIMEOUT) # while the user is present in the channel. def present(user_id:, client_id:) raise PresenceChannel::InvalidAccess if !can_enter?(user_id: user_id) mutex_value = SecureRandom.hex result = retry_on_mutex_error do PresenceChannel.redis_eval( :present, redis_keys, [name, user_id, client_id, (Time.zone.now + timeout).to_i, mutex_value], ) end if result == 1 begin publish_message(entering_user_ids: [user_id]) ensure release_mutex(mutex_value) end end end # Immediately mark a user's client as leaving the channel def leave(user_id:, client_id:) mutex_value = SecureRandom.hex result = retry_on_mutex_error do PresenceChannel.redis_eval(:leave, redis_keys, [name, user_id, client_id, nil, mutex_value]) end if result == 1 begin publish_message(leaving_user_ids: [user_id]) ensure release_mutex(mutex_value) end end end # Fetch a {PresenceChannel::State} instance representing the current state of this # # @param [Boolean] count_only set true to skip fetching the list of user ids from redis def state(count_only: config.count_only) if count_only last_id, count = retry_on_mutex_error { PresenceChannel.redis_eval(:count, redis_keys) } else last_id, ids = retry_on_mutex_error { PresenceChannel.redis_eval(:user_ids, redis_keys) } end count ||= ids&.count last_id = nil if last_id == -1 if Rails.env.test? && MessageBus.backend == :memory # Doing it this way is not atomic, but we have no other option when # messagebus is not using the redis backend last_id = MessageBus.last_id(message_bus_channel_name) end State.new(message_bus_last_id: last_id, user_ids: ids, count: count) end def user_ids state.user_ids end def count state(count_only: true).count end # Automatically expire all users which have not been 'present' for more than +DEFAULT_TIMEOUT+ def auto_leave mutex_value = SecureRandom.hex left_user_ids = retry_on_mutex_error do PresenceChannel.redis_eval(:auto_leave, redis_keys, [name, Time.zone.now.to_i, mutex_value]) end if !left_user_ids.empty? begin publish_message(leaving_user_ids: left_user_ids) ensure release_mutex(mutex_value) end end end # Clear all members of the channel. This is intended for debugging/development only def clear PresenceChannel.redis.del(redis_key_zlist) PresenceChannel.redis.del(redis_key_hash) PresenceChannel.redis.del(redis_key_config) PresenceChannel.redis.del(redis_key_mutex) PresenceChannel.redis.zrem(self.class.redis_key_channel_list, name) end # Designed to be run periodically. Checks the channel list for channels with expired members, # and runs auto_leave for each eligible channel def self.auto_leave_all channels_with_expiring_members = PresenceChannel.redis.zrangebyscore(redis_key_channel_list, "-inf", Time.zone.now.to_i) channels_with_expiring_members.each { |name| new(name, raise_not_found: false).auto_leave } end # Clear all known channels. This is intended for debugging/development only def self.clear_all! channels = PresenceChannel.redis.zrangebyscore(redis_key_channel_list, "-inf", "+inf") channels.each { |name| new(name, raise_not_found: false).clear } config_cache_keys = PresenceChannel .redis .scan_each(match: Discourse.redis.namespace_key("_presence_*_config")) .to_a PresenceChannel.redis.del(*config_cache_keys) if config_cache_keys.present? end # Shortcut to access a redis client for all PresenceChannel activities. # PresenceChannel must use the same Redis server as MessageBus, so that # actions can be applied atomically. For the vast majority of Discourse # installations, this is the same Redis server as `Discourse.redis`. def self.redis if MessageBus.backend == :redis MessageBus.backend_instance.send(:pub_redis) # TODO: avoid a private API? elsif Rails.env.test? Discourse.redis.without_namespace else raise "PresenceChannel is unable to access MessageBus's Redis instance" end end def self.redis_eval(key, *args) LUA_SCRIPTS[key].eval(redis, *args) end # Register a callback to configure channels with a given prefix # Prefix must match [a-zA-Z0-9_-]+ # # For example, this registration will be used for # all channels starting /topic-reply/...: # # register_prefix("topic-reply") do |channel_name| # PresenceChannel::Config.new(public: true) # end # # At runtime, the block will be passed a full channel name. If the channel # should not exist, the block should return `nil`. If the channel should exist, # the block should return a PresenceChannel::Config object. # # Return values may be cached for up to 10 seconds. # # Plugins should use the {Plugin::Instance.register_presence_channel_prefix} API instead def self.register_prefix(prefix, &block) unless prefix.match? /[a-zA-Z0-9_-]+/ raise "PresenceChannel prefix #{prefix} must match [a-zA-Z0-9_-]+" end if @@configuration_blocks&.[](prefix) raise "PresenceChannel prefix #{prefix} already registered" end @@configuration_blocks[prefix] = block end # For use in a test environment only def self.unregister_prefix(prefix) raise "Only allowed in test environment" if !Rails.env.test? @@configuration_blocks&.delete(prefix) end private def fetch_config(use_cache: true) cached_config = (PresenceChannel.redis.get(redis_key_config) if use_cache) if cached_config == Config::NOT_FOUND raise PresenceChannel::NotFound elsif cached_config Config.from_json(cached_config) else prefix = name[%r{/([a-zA-Z0-9_-]+)/.*}, 1] raise PresenceChannel::NotFound if prefix.nil? config_block = @@configuration_blocks[prefix] config_block ||= DiscoursePluginRegistry.presence_channel_prefixes.find { |t| t[0] == prefix }&.[](1) raise PresenceChannel::NotFound if config_block.nil? result = config_block.call(name) to_cache = if result.is_a? Config result.to_json elsif result.nil? Config::NOT_FOUND else raise InvalidConfig.new "Expected PresenceChannel::Config or nil. Got a #{result.class.name}" end DiscourseRedis.ignore_readonly do PresenceChannel.redis.set(redis_key_config, to_cache, ex: CONFIG_CACHE_SECONDS) end raise PresenceChannel::NotFound if result.nil? result end end def publish_message(entering_user_ids: nil, leaving_user_ids: nil) message = {} if config.count_only message["count_delta"] = entering_user_ids&.count || 0 message["count_delta"] -= leaving_user_ids&.count || 0 return if message["count_delta"] == 0 else message["leaving_user_ids"] = leaving_user_ids if leaving_user_ids.present? if entering_user_ids.present? users = User.where(id: entering_user_ids).includes(:user_option) message["entering_users"] = ActiveModel::ArraySerializer.new( users, each_serializer: BasicUserSerializer, ) end end params = {} if config.public # no params required elsif config.allowed_user_ids || config.allowed_group_ids params[:user_ids] = config.allowed_user_ids params[:group_ids] = config.allowed_group_ids else # nobody is allowed... don't publish anything return end MessageBus.publish(message_bus_channel_name, message.as_json, **params) end # Most atomic actions are achieved via lua scripts. However, when a lua action # will result in publishing a messagebus message, the atomicity is broken. # # For example, if one process is handling a 'user enter' event, and another is # handling a 'user leave' event, we need to make sure the messagebus messages # are published in the same sequence that the PresenceChannel lua script are run. # # The present/leave/auto_leave lua scripts will automatically acquire this mutex # if needed. If their return value indicates a change has occurred, the mutex # should be released via #release_mutex after the messagebus message has been sent # # If they need a change, and the mutex is not available, they will raise an error # and should be retried periodically def redis_key_mutex Discourse.redis.namespace_key("_presence_#{name}_mutex") end def release_mutex(mutex_value) PresenceChannel.redis_eval(:release_mutex, [redis_key_mutex], [mutex_value]) end def retry_on_mutex_error attempts ||= 0 yield rescue ::Redis::CommandError => e if e.to_s =~ /#{MUTEX_LOCKED_ERROR}/ && attempts < 1000 attempts += 1 sleep 0.001 retry else raise end end # The redis key which MessageBus uses to store the 'last_id' for the channel # associated with this PresenceChannel. def message_bus_last_id_key return "" if Rails.env.test? && MessageBus.backend == :memory # TODO: Avoid using private MessageBus methods here encoded_channel_name = MessageBus.send(:encode_channel_name, message_bus_channel_name) MessageBus.backend_instance.send(:backlog_id_key, encoded_channel_name) end def redis_keys [ redis_key_zlist, redis_key_hash, self.class.redis_key_channel_list, message_bus_last_id_key, redis_key_mutex, ] end # The zlist is a list of client_ids, ranked by their expiration timestamp # we periodically delete the 'lowest ranked' items in this list based on the `timeout` of the channel def redis_key_zlist Discourse.redis.namespace_key("_presence_#{name}_zlist") end # The hash contains a map of user_id => session_count # when the count for a user reaches 0, the key is deleted # We use this hash to efficiently count the number of present users def redis_key_hash Discourse.redis.namespace_key("_presence_#{name}_hash") end # The hash contains a map of user_id => session_count # when the count for a user reaches 0, the key is deleted # We use this hash to efficiently count the number of present users def redis_key_config Discourse.redis.namespace_key("_presence_#{name}_config") end # This list contains all active presence channels, ranked with the expiration timestamp of their least-recently-seen client_id # We periodically check the 'lowest ranked' items in this list based on the `timeout` of the channel def self.redis_key_channel_list Discourse.redis.namespace_key("_presence_channels") end COMMON_PRESENT_LEAVE_LUA = <<~LUA local channel = ARGV[1] local user_id = ARGV[2] local client_id = ARGV[3] local expires = ARGV[4] local mutex_value = ARGV[5] local zlist_key = KEYS[1] local hash_key = KEYS[2] local channels_key = KEYS[3] local message_bus_id_key = KEYS[4] local mutex_key = KEYS[5] local mutex_locked = redis.call('EXISTS', mutex_key) == 1 local zlist_elem = tostring(user_id) .. " " .. tostring(client_id) LUA UPDATE_GLOBAL_CHANNELS_LUA = <<~LUA -- Update the global channels list with the timestamp of the oldest client local oldest_client = redis.call('ZRANGE', zlist_key, 0, 0, 'WITHSCORES') if table.getn(oldest_client) > 0 then local oldest_client_expire_timestamp = oldest_client[2] redis.call('ZADD', channels_key, tonumber(oldest_client_expire_timestamp), tostring(channel)) else -- The channel is now empty, delete from global list redis.call('ZREM', channels_key, tostring(channel)) end LUA LUA_SCRIPTS ||= {} LUA_SCRIPTS[:present] = DiscourseRedis::EvalHelper.new <<~LUA #{COMMON_PRESENT_LEAVE_LUA} if mutex_locked then local mutex_required = redis.call('HGET', hash_key, tostring(user_id)) == false if mutex_required then error("#{MUTEX_LOCKED_ERROR}") end end local added_clients = redis.call('ZADD', zlist_key, expires, zlist_elem) local added_users = 0 if tonumber(added_clients) > 0 then local new_count = redis.call('HINCRBY', hash_key, tostring(user_id), 1) if new_count == 1 then added_users = 1 redis.call('SET', mutex_key, mutex_value, 'EX', #{MUTEX_TIMEOUT_SECONDS}) end -- Add the channel to the global channel list. 'NX' means the value will -- only be set if doesn't already exist redis.call('ZADD', channels_key, "NX", expires, tostring(channel)) end redis.call('EXPIREAT', hash_key, expires + #{GC_SECONDS}) redis.call('EXPIREAT', zlist_key, expires + #{GC_SECONDS}) return added_users LUA LUA_SCRIPTS[:leave] = DiscourseRedis::EvalHelper.new <<~LUA #{COMMON_PRESENT_LEAVE_LUA} if mutex_locked then local user_session_count = redis.call('HGET', hash_key, tostring(user_id)) local mutex_required = user_session_count == 1 and redis.call('ZRANK', zlist_key, zlist_elem) ~= false if mutex_required then error("#{MUTEX_LOCKED_ERROR}") end end -- Remove the user from the channel zlist local removed_clients = redis.call('ZREM', zlist_key, zlist_elem) local removed_users = 0 if tonumber(removed_clients) > 0 then #{UPDATE_GLOBAL_CHANNELS_LUA} -- Update the user session count in the channel hash local val = redis.call('HINCRBY', hash_key, user_id, #{Discourse::SYSTEM_USER_ID}) if val <= 0 then redis.call('HDEL', hash_key, user_id) removed_users = 1 redis.call('SET', mutex_key, mutex_value, 'EX', #{MUTEX_TIMEOUT_SECONDS}) end end return removed_users LUA LUA_SCRIPTS[:release_mutex] = DiscourseRedis::EvalHelper.new <<~LUA local mutex_key = KEYS[1] local expected_value = ARGV[1] if redis.call("GET", mutex_key) == expected_value then redis.call("DEL", mutex_key) end LUA LUA_SCRIPTS[:user_ids] = DiscourseRedis::EvalHelper.new <<~LUA local zlist_key = KEYS[1] local hash_key = KEYS[2] local message_bus_id_key = KEYS[4] local mutex_key = KEYS[5] if redis.call('EXISTS', mutex_key) > 0 then error('#{MUTEX_LOCKED_ERROR}') end local user_ids = redis.call('HKEYS', hash_key) table.foreach(user_ids, function(k,v) user_ids[k] = tonumber(v) end) local message_bus_id = tonumber(redis.call('GET', message_bus_id_key)) if message_bus_id == nil then message_bus_id = -1 end return { message_bus_id, user_ids } LUA LUA_SCRIPTS[:count] = DiscourseRedis::EvalHelper.new <<~LUA local zlist_key = KEYS[1] local hash_key = KEYS[2] local message_bus_id_key = KEYS[4] local mutex_key = KEYS[5] if redis.call('EXISTS', mutex_key) > 0 then error('#{MUTEX_LOCKED_ERROR}') end local message_bus_id = tonumber(redis.call('GET', message_bus_id_key)) if message_bus_id == nil then message_bus_id = -1 end local count = redis.call('HLEN', hash_key) return { message_bus_id, count } LUA LUA_SCRIPTS[:auto_leave] = DiscourseRedis::EvalHelper.new <<~LUA local zlist_key = KEYS[1] local hash_key = KEYS[2] local channels_key = KEYS[3] local mutex_key = KEYS[5] local channel = ARGV[1] local time = ARGV[2] local mutex_value = ARGV[3] local expire = redis.call('ZRANGEBYSCORE', zlist_key, '-inf', time) local has_mutex = false local get_mutex = function() if redis.call('SETNX', mutex_key, mutex_value) == 0 then error("#{MUTEX_LOCKED_ERROR}") end redis.call('EXPIRE', mutex_key, #{MUTEX_TIMEOUT_SECONDS}) has_mutex = true end local expired_user_ids = {} local expireOld = function(k, v) local user_id = v:match("[^ ]+") if (not has_mutex) and (tonumber(redis.call('HGET', hash_key, user_id)) == 1) then get_mutex() end local val = redis.call('HINCRBY', hash_key, user_id, #{Discourse::SYSTEM_USER_ID}) if val <= 0 then table.insert(expired_user_ids, tonumber(user_id)) redis.call('HDEL', hash_key, user_id) end redis.call('ZREM', zlist_key, v) end table.foreach(expire, expireOld) #{UPDATE_GLOBAL_CHANNELS_LUA} return expired_user_ids LUA end