Class: PresenceChannel

Inherits:
Object
  • Object
show all
Defined in:
lib/presence_channel.rb

Overview

The server-side implementation of PresenceChannels. See also PresenceController and app/assets/javascripts/discourse/app/services/presence.js

Defined Under Namespace

Classes: Config, ConfigNotLoaded, InvalidAccess, InvalidConfig, NotFound, State

Constant Summary collapse

DEFAULT_TIMEOUT =
60
CONFIG_CACHE_SECONDS =
10
GC_SECONDS =
24.hours.to_i
MUTEX_TIMEOUT_SECONDS =
10
MUTEX_LOCKED_ERROR =
"PresenceChannel mutex is locked"

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name, raise_not_found: true, use_cache: true) ⇒ PresenceChannel

Returns a new instance of PresenceChannel.



89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/presence_channel.rb', line 89

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

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



87
88
89
# File 'lib/presence_channel.rb', line 87

def config
  @config
end

#message_bus_channel_nameObject (readonly)

Returns the value of attribute message_bus_channel_name.



87
88
89
# File 'lib/presence_channel.rb', line 87

def message_bus_channel_name
  @message_bus_channel_name
end

#nameObject (readonly)

Returns the value of attribute name.



87
88
89
# File 'lib/presence_channel.rb', line 87

def name
  @name
end

#timeoutObject (readonly)

Returns the value of attribute timeout.



87
88
89
# File 'lib/presence_channel.rb', line 87

def timeout
  @timeout
end

Class Method Details

.auto_leave_allObject

Designed to be run periodically. Checks the channel list for channels with expired members, and runs auto_leave for each eligible channel



223
224
225
226
227
# File 'lib/presence_channel.rb', line 223

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!Object

Clear all known channels. This is intended for debugging/development only



230
231
232
233
234
235
236
237
238
239
240
# File 'lib/presence_channel.rb', line 230

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

.redisObject

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`.



246
247
248
249
250
251
252
253
254
# File 'lib/presence_channel.rb', line 246

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

.redis_eval(key, *args) ⇒ Object



256
257
258
# File 'lib/presence_channel.rb', line 256

def self.redis_eval(key, *args)
  LUA_SCRIPTS[key].eval(redis, *args)
end

.register_prefix(prefix, &block) ⇒ Object

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



277
278
279
280
281
282
283
284
285
# File 'lib/presence_channel.rb', line 277

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

.unregister_prefix(prefix) ⇒ Object

For use in a test environment only



288
289
290
291
# File 'lib/presence_channel.rb', line 288

def self.unregister_prefix(prefix)
  raise "Only allowed in test environment" if !Rails.env.test?
  @@configuration_blocks&.delete(prefix)
end

Instance Method Details

#auto_leaveObject

Automatically expire all users which have not been ‘present’ for more than DEFAULT_TIMEOUT



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/presence_channel.rb', line 196

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

#can_enter?(user_id: nil, group_ids: nil) ⇒ Boolean

Is a user allowed to enter this channel? Currently equal to the can_view? permission

Returns:

  • (Boolean)


119
120
121
122
# File 'lib/presence_channel.rb', line 119

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

#can_view?(user_id: nil, group_ids: nil) ⇒ Boolean

Is this user allowed to view this channel? Pass ‘nil` for anonymous viewers

Returns:

  • (Boolean)


105
106
107
108
109
110
111
112
113
114
115
# File 'lib/presence_channel.rb', line 105

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

#clearObject

Clear all members of the channel. This is intended for debugging/development only



213
214
215
216
217
218
219
# File 'lib/presence_channel.rb', line 213

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

#countObject



191
192
193
# File 'lib/presence_channel.rb', line 191

def count
  state(count_only: true).count
end

#leave(user_id:, client_id:) ⇒ Object

Immediately mark a user’s client as leaving the channel



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/presence_channel.rb', line 150

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

#present(user_id:, client_id:) ⇒ Object

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.



127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/presence_channel.rb', line 127

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

#state(count_only: config.count_only) ⇒ Object

Fetch a State instance representing the current state of this

Parameters:

  • count_only (Boolean) (defaults to: config.count_only)

    set true to skip fetching the list of user ids from redis



169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/presence_channel.rb', line 169

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

#user_idsObject



187
188
189
# File 'lib/presence_channel.rb', line 187

def user_ids
  state.user_ids
end