vines 0.3.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (115) hide show
  1. data/README +5 -9
  2. data/Rakefile +11 -9
  3. data/conf/config.rb +30 -4
  4. data/lib/vines/cluster/connection.rb +26 -0
  5. data/lib/vines/cluster/publisher.rb +55 -0
  6. data/lib/vines/cluster/pubsub.rb +92 -0
  7. data/lib/vines/cluster/sessions.rb +125 -0
  8. data/lib/vines/cluster/subscriber.rb +108 -0
  9. data/lib/vines/cluster.rb +246 -0
  10. data/lib/vines/command/init.rb +21 -24
  11. data/lib/vines/config/host.rb +48 -8
  12. data/lib/vines/config/port.rb +5 -0
  13. data/lib/vines/config/pubsub.rb +108 -0
  14. data/lib/vines/config.rb +74 -20
  15. data/lib/vines/jid.rb +14 -0
  16. data/lib/vines/router.rb +69 -55
  17. data/lib/vines/stanza/iq/disco_info.rb +22 -9
  18. data/lib/vines/stanza/iq/disco_items.rb +6 -3
  19. data/lib/vines/stanza/iq/ping.rb +1 -1
  20. data/lib/vines/stanza/iq/private_storage.rb +4 -8
  21. data/lib/vines/stanza/iq/roster.rb +6 -14
  22. data/lib/vines/stanza/iq/session.rb +2 -7
  23. data/lib/vines/stanza/iq/vcard.rb +4 -6
  24. data/lib/vines/stanza/iq/version.rb +1 -1
  25. data/lib/vines/stanza/iq.rb +8 -10
  26. data/lib/vines/stanza/presence/subscribe.rb +3 -11
  27. data/lib/vines/stanza/presence/subscribed.rb +16 -29
  28. data/lib/vines/stanza/presence/unsubscribe.rb +3 -15
  29. data/lib/vines/stanza/presence/unsubscribed.rb +3 -16
  30. data/lib/vines/stanza/presence.rb +30 -0
  31. data/lib/vines/stanza/pubsub/create.rb +39 -0
  32. data/lib/vines/stanza/pubsub/delete.rb +41 -0
  33. data/lib/vines/stanza/pubsub/publish.rb +66 -0
  34. data/lib/vines/stanza/pubsub/subscribe.rb +44 -0
  35. data/lib/vines/stanza/pubsub/unsubscribe.rb +30 -0
  36. data/lib/vines/stanza/pubsub.rb +22 -0
  37. data/lib/vines/stanza.rb +72 -22
  38. data/lib/vines/storage/couchdb.rb +46 -65
  39. data/lib/vines/storage/local.rb +20 -14
  40. data/lib/vines/storage/mongodb.rb +132 -0
  41. data/lib/vines/storage/null.rb +39 -0
  42. data/lib/vines/storage/redis.rb +61 -68
  43. data/lib/vines/storage/sql.rb +73 -69
  44. data/lib/vines/storage.rb +1 -1
  45. data/lib/vines/stream/client/bind.rb +2 -2
  46. data/lib/vines/stream/client/session.rb +71 -16
  47. data/lib/vines/stream/component/handshake.rb +1 -0
  48. data/lib/vines/stream/component/ready.rb +2 -2
  49. data/lib/vines/stream/http/session.rb +2 -0
  50. data/lib/vines/stream/http.rb +0 -6
  51. data/lib/vines/stream/server/final_restart.rb +1 -0
  52. data/lib/vines/stream/server/outbound/final_features.rb +1 -0
  53. data/lib/vines/stream/server/ready.rb +6 -2
  54. data/lib/vines/stream/server.rb +4 -3
  55. data/lib/vines/stream.rb +10 -6
  56. data/lib/vines/version.rb +1 -1
  57. data/lib/vines.rb +48 -22
  58. data/test/cluster/publisher_test.rb +45 -0
  59. data/test/cluster/sessions_test.rb +54 -0
  60. data/test/cluster/subscriber_test.rb +94 -0
  61. data/test/config/host_test.rb +100 -21
  62. data/test/config/pubsub_test.rb +181 -0
  63. data/test/config_test.rb +225 -43
  64. data/test/jid_test.rb +7 -0
  65. data/test/router_test.rb +181 -9
  66. data/test/stanza/iq/disco_info_test.rb +8 -6
  67. data/test/stanza/iq/disco_items_test.rb +3 -3
  68. data/test/stanza/iq/private_storage_test.rb +8 -19
  69. data/test/stanza/iq/roster_test.rb +1 -1
  70. data/test/stanza/iq/session_test.rb +3 -6
  71. data/test/stanza/iq/vcard_test.rb +6 -2
  72. data/test/stanza/iq/version_test.rb +3 -2
  73. data/test/stanza/iq_test.rb +5 -5
  74. data/test/stanza/message_test.rb +3 -2
  75. data/test/stanza/presence/probe_test.rb +2 -1
  76. data/test/stanza/pubsub/create_test.rb +138 -0
  77. data/test/stanza/pubsub/delete_test.rb +142 -0
  78. data/test/stanza/pubsub/publish_test.rb +373 -0
  79. data/test/stanza/pubsub/subscribe_test.rb +186 -0
  80. data/test/stanza/pubsub/unsubscribe_test.rb +179 -0
  81. data/test/stanza_test.rb +2 -1
  82. data/test/storage/local_test.rb +26 -25
  83. data/test/storage/mock_mongo.rb +40 -0
  84. data/test/storage/mock_redis.rb +98 -0
  85. data/test/storage/mongodb_test.rb +81 -0
  86. data/test/storage/null_test.rb +30 -0
  87. data/test/storage/redis_test.rb +3 -36
  88. data/test/stream/component/handshake_test.rb +4 -0
  89. data/test/stream/component/ready_test.rb +2 -1
  90. data/test/stream/server/ready_test.rb +7 -1
  91. data/web/404.html +5 -3
  92. data/web/chat/coffeescripts/chat.coffee +9 -5
  93. data/web/chat/javascripts/app.js +1 -1
  94. data/web/chat/javascripts/chat.js +14 -8
  95. data/web/chat/stylesheets/chat.css +4 -1
  96. data/web/lib/coffeescripts/button.coffee +9 -5
  97. data/web/lib/coffeescripts/filter.coffee +1 -1
  98. data/web/lib/coffeescripts/login.coffee +14 -1
  99. data/web/lib/coffeescripts/session.coffee +8 -11
  100. data/web/lib/images/dark-gray.png +0 -0
  101. data/web/lib/images/light-gray.png +0 -0
  102. data/web/lib/images/logo-large.png +0 -0
  103. data/web/lib/images/logo-small.png +0 -0
  104. data/web/lib/images/white.png +0 -0
  105. data/web/lib/javascripts/base.js +9 -8
  106. data/web/lib/javascripts/button.js +20 -12
  107. data/web/lib/javascripts/filter.js +1 -1
  108. data/web/lib/javascripts/icons.js +7 -1
  109. data/web/lib/javascripts/jquery.js +4 -4
  110. data/web/lib/javascripts/login.js +16 -2
  111. data/web/lib/javascripts/raphael.js +5 -7
  112. data/web/lib/javascripts/session.js +10 -14
  113. data/web/lib/stylesheets/base.css +7 -11
  114. data/web/lib/stylesheets/login.css +31 -27
  115. metadata +100 -34
@@ -0,0 +1,246 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ # Server instances may be connected to one another in a cluster so they
5
+ # can host a single chat domain, or set of domains, across many servers,
6
+ # transparently to users. A redis database is used for the session routing
7
+ # table, mapping JIDs to their node's location. Redis pubsub channels are
8
+ # used to communicate amongst nodes.
9
+ #
10
+ # Using a shared in-memory cache, like redis, rather than synchronizing the
11
+ # cache to each node, allows us to add cluster nodes dynamically, without
12
+ # updating all other nodes' config files. It also greatly reduces the amount
13
+ # of memory required by the chat server processes.
14
+ class Cluster
15
+ include Vines::Log
16
+
17
+ attr_reader :id
18
+
19
+ %w[host port database password].each do |name|
20
+ define_method(name) do |*args|
21
+ if args.first
22
+ @connection.send("#{name}=", args.first)
23
+ else
24
+ @connection.send(name)
25
+ end
26
+ end
27
+ end
28
+
29
+ def initialize(config, &block)
30
+ @config, @id = config, Kit.uuid
31
+ @connection = Connection.new
32
+ @sessions = Sessions.new(self)
33
+ @publisher = Publisher.new(self)
34
+ @subscriber = Subscriber.new(self)
35
+ @pubsub = PubSub.new(self)
36
+ instance_eval(&block)
37
+ end
38
+
39
+ # Join this node to the cluster by broadcasting its state to the
40
+ # other nodes, subscribing to redis channels, and scheduling periodic
41
+ # heartbeat broadcasts. This method must be called after initialize
42
+ # or this node will not be a cluster member.
43
+ def start
44
+ @connection.connect
45
+ @publisher.broadcast(:online)
46
+ @subscriber.subscribe
47
+
48
+ EM.add_periodic_timer(1) { heartbeat }
49
+
50
+ at_exit do
51
+ @publisher.broadcast(:offline)
52
+ @sessions.delete_all(@id)
53
+ end
54
+ end
55
+
56
+ # Returns any streams hosted at remote nodes for these JIDs. The streams act
57
+ # like normal EM::Connections, but are actually proxies that route stanzas
58
+ # over redis pubsub channels to remote nodes.
59
+ def remote_sessions(*jids)
60
+ @sessions.find(*jids).map do |session|
61
+ StreamProxy.new(self, session)
62
+ end
63
+ end
64
+
65
+ # Persist the user's session to the shared redis cache so that other cluster
66
+ # nodes can locate the node hosting this user's connection and route messages
67
+ # to them.
68
+ def save_session(jid, attrs)
69
+ @sessions.save(jid, attrs)
70
+ end
71
+
72
+ # Remove this user from the cluster routing table so that no further stanzas
73
+ # may be routed to them. This must be called when the user's session is
74
+ # terminated, either by logout or stream disconnect.
75
+ def delete_session(jid)
76
+ @sessions.delete(jid)
77
+ end
78
+
79
+ # Remove all user sessions from the routing table associated with the
80
+ # given node ID. Cluster nodes call this themselves during normal shutdown.
81
+ # However, if a node dies without being properly shutdown, the other nodes
82
+ # will cleanup its sessions when they detect the node is offline.
83
+ def delete_sessions(node)
84
+ @sessions.delete_all(node)
85
+ end
86
+
87
+ # Notify the session store that this node is still alive. The node
88
+ # broadcasts its current time, so all cluster members' clocks don't
89
+ # necessarily need to be in sync.
90
+ def poke(node, time)
91
+ @sessions.poke(node, time)
92
+ end
93
+
94
+ # Send the stanza to the node hosting the user's session. The stanza is
95
+ # published to the channel to which the remote node is listening for
96
+ # messages.
97
+ def route(stanza, node)
98
+ @publisher.route(stanza, node)
99
+ end
100
+
101
+ # Notify the remote node that the user's roster has changed and it should
102
+ # reload the user from storage.
103
+ def update_user(jid, node)
104
+ @publisher.update_user(jid, node)
105
+ end
106
+
107
+ # Return the shared redis connection for most queries to use.
108
+ def connection
109
+ @connection.connect
110
+ end
111
+
112
+ # Create a new redis connection.
113
+ def connect
114
+ @connection.create
115
+ end
116
+
117
+ # Turn an asynchronous redis query into a blocking call by pausing the
118
+ # fiber in which this code is running. Return the result of the query
119
+ # from this method, rather than passing it to a callback block.
120
+ def query(name, *args)
121
+ fiber, yielding = Fiber.current, true
122
+ req = connection.send(name, *args)
123
+ req.errback { fiber.resume rescue yielding = false }
124
+ req.callback {|response| fiber.resume(response) }
125
+ Fiber.yield if yielding
126
+ end
127
+
128
+ # Return the connected streams for this user, without any proxy streams
129
+ # to remote cluster nodes (locally connected streams only).
130
+ def connected_resources(jid)
131
+ @config.router.connected_resources(jid, jid, false)
132
+ end
133
+
134
+ # Return the Storage implementation for this domain or nil if the
135
+ # domain is not hosted here.
136
+ def storage(domain)
137
+ @config.storage(domain)
138
+ end
139
+
140
+ # Create a pubsub topic (a.k.a. node), in the given domain, to which
141
+ # messages may be published. The domain argument will be one of the
142
+ # configured pubsub subdomains in conf/config.rb (e.g. games.wonderland.lit,
143
+ # topics.wonderland.lit, etc).
144
+ def add_pubsub_node(domain, node)
145
+ @pubsub.add_node(domain, node)
146
+ end
147
+
148
+ # Remove a pubsub topic so messages may no longer be broadcast to it.
149
+ def delete_pubsub_node(domain, node)
150
+ @pubsub.delete_node(domain, node)
151
+ end
152
+
153
+ # Subscribe the JID to the pubsub topic so it will receive any messages
154
+ # published to it.
155
+ def subscribe_pubsub(domain, node, jid)
156
+ @pubsub.subscribe(domain, node, jid)
157
+ end
158
+
159
+ # Unsubscribe the JID from the pubsub topic, deregistering its interest
160
+ # in receiving any messages published to it.
161
+ def unsubscribe_pubsub(domain, node, jid)
162
+ @pubsub.unsubscribe(domain, node, jid)
163
+ end
164
+
165
+ # Unsubscribe the JID from all pubsub topics. This is useful when the
166
+ # JID's session ends by logout or disconnect.
167
+ def unsubscribe_all_pubsub(domain, jid)
168
+ @pubsub.unsubscribe_all(domain, jid)
169
+ end
170
+
171
+ # Return true if the pubsub topic exists and messages may be published to it.
172
+ def pubsub_node?(domain, node)
173
+ @pubsub.node?(domain, node)
174
+ end
175
+
176
+ # Return true if the JID is a registered subscriber to the pubsub topic and
177
+ # messages published to it should be routed to the JID.
178
+ def pubsub_subscribed?(domain, node, jid)
179
+ @pubsub.subscribed?(domain, node, jid)
180
+ end
181
+
182
+ # Return a list of JIDs subscribed to the pubsub topic.
183
+ def pubsub_subscribers(domain, node)
184
+ @pubsub.subscribers(domain, node)
185
+ end
186
+
187
+ private
188
+
189
+ # Call this method once per second to broadcast this node's heartbeat and
190
+ # expire stale user sessions. This method must not raise exceptions or the
191
+ # timer will stop.
192
+ def heartbeat
193
+ @publisher.broadcast(:heartbeat)
194
+ @sessions.expire
195
+ rescue Exception => e
196
+ log.error("Cluster session cleanup failed: #{e}")
197
+ end
198
+
199
+ # StreamProxy behaves like an EM::Connection so that stanzas may be sent to
200
+ # remote nodes just as they are to locally connected streams. The rest of the
201
+ # system doesn't know or care that these "streams" send their traffic over
202
+ # redis pubsub channels.
203
+ class StreamProxy
204
+ attr_reader :user
205
+
206
+ def initialize(cluster, session)
207
+ @cluster, @user = cluster, UserProxy.new(cluster, session)
208
+ @node, @available, @interested, @presence =
209
+ session.values_at('node', 'available', 'interested', 'presence')
210
+
211
+ unless @presence.nil? || @presence.empty?
212
+ @presence = Nokogiri::XML(@presence).root rescue nil
213
+ end
214
+ end
215
+
216
+ def available?
217
+ @available
218
+ end
219
+
220
+ def interested?
221
+ @interested
222
+ end
223
+
224
+ def last_broadcast_presence
225
+ @presence
226
+ end
227
+
228
+ def write(stanza)
229
+ @cluster.route(stanza, @node)
230
+ end
231
+ end
232
+
233
+ # Proxy User#update_from calls to remote cluster nodes over redis
234
+ # pubsub channels.
235
+ class UserProxy < User
236
+ def initialize(cluster, session)
237
+ super(jid: session['jid'])
238
+ @cluster, @node = cluster, session['node']
239
+ end
240
+
241
+ def update_from(user)
242
+ @cluster.update_user(@jid.bare, @node)
243
+ end
244
+ end
245
+ end
246
+ end
@@ -5,21 +5,14 @@ module Vines
5
5
  class Init
6
6
  def run(opts)
7
7
  raise 'vines init <domain>' unless opts[:args].size == 1
8
- domain = opts[:args].first
8
+ domain = opts[:args].first.downcase
9
9
  dir = File.expand_path(domain)
10
10
  raise "Directory already initialized: #{domain}" if File.exists?(dir)
11
11
  Dir.mkdir(dir)
12
12
 
13
- %w[conf web].each do |sub|
14
- FileUtils.cp_r(File.expand_path("../../../../#{sub}", __FILE__), dir)
15
- end
16
- users, log, pid = %w[data/users log pid].map do |sub|
17
- File.join(dir, sub).tap {|subdir| FileUtils.makedirs(subdir) }
18
- end
19
-
20
- create_users(domain, users)
21
- update_config(domain, File.join(dir, 'conf', 'config.rb'))
22
- fix_perms(dir)
13
+ create_directories(dir)
14
+ create_users(domain, dir)
15
+ update_config(domain, dir)
23
16
  Command::Cert.new.create_cert(domain, File.join(dir, 'conf/certs'))
24
17
 
25
18
  puts "Initialized server directory: #{domain}"
@@ -31,36 +24,40 @@ module Vines
31
24
  # Limit file system database directory access so the server is the only
32
25
  # process managing the data. The config.rb file contains component and
33
26
  # database passwords, so restrict access to just the server user as well.
34
- def fix_perms(dir)
35
- %w[data data/users].each do |f|
36
- File.chmod(0700, File.join(dir, f))
27
+ def create_directories(dir)
28
+ %w[conf web].each do |sub|
29
+ FileUtils.cp_r(File.expand_path("../../../../#{sub}", __FILE__), dir)
30
+ end
31
+ %w[data log pid].each do |sub|
32
+ Dir.mkdir(File.join(dir, sub), 0700)
37
33
  end
38
34
  File.chmod(0600, File.join(dir, 'conf/config.rb'))
39
35
  end
40
36
 
41
- def update_config(domain, config)
37
+ def update_config(domain, dir)
38
+ config = File.expand_path('conf/config.rb', dir)
42
39
  text = File.read(config)
43
40
  File.open(config, 'w') do |f|
44
- f.write(text.gsub('wonderland.lit', domain.downcase))
41
+ f.write(text.gsub('wonderland.lit', domain))
45
42
  end
46
43
  end
47
44
 
48
45
  def create_users(domain, dir)
49
46
  password = 'secr3t'
50
47
  alice, arthur = %w[alice arthur].map do |jid|
51
- User.new(:jid => [jid, domain.downcase].join('@'),
52
- :password => BCrypt::Password.create(password).to_s)
48
+ User.new(jid: [jid, domain].join('@'),
49
+ password: BCrypt::Password.create(password).to_s)
53
50
  end
54
51
 
55
52
  [[alice, arthur], [arthur, alice]].each do |user, contact|
56
- user.roster << Contact.new(
57
- :jid => contact.jid,
58
- :name => contact.jid.node.capitalize,
59
- :subscription => 'both',
60
- :groups => %w[Buddies])
53
+ user.roster << Contact.new(
54
+ jid: contact.jid,
55
+ name: contact.jid.node.capitalize,
56
+ subscription: 'both',
57
+ groups: %w[Buddies])
61
58
  end
62
59
 
63
- storage = Storage::Local.new { dir(dir) }
60
+ storage = Storage::Local.new { dir(File.join(dir, 'data')) }
64
61
  [alice, arthur].each do |user|
65
62
  storage.save_user(user)
66
63
  puts "Created example user #{user.jid} with password #{password}"
@@ -7,11 +7,14 @@ module Vines
7
7
  # conf/config.rb file. Host instances can be accessed at runtime through
8
8
  # the +Config#vhosts+ method.
9
9
  class Host
10
- def initialize(name, &block)
11
- @name, @storage, @ldap = name.downcase, nil, nil
10
+ attr_reader :pubsubs
11
+
12
+ def initialize(config, name, &block)
13
+ @config, @name = config, name.downcase
14
+ @storage, @ldap = nil, nil
12
15
  @cross_domain_messages = false
13
16
  @private_storage = false
14
- @components = {}
17
+ @components, @pubsubs = {}, {}
15
18
  validate_domain(@name)
16
19
  instance_eval(&block)
17
20
  raise "storage required for #{@name}" unless @storage
@@ -44,8 +47,8 @@ module Vines
44
47
  return @components unless options
45
48
 
46
49
  names = options.keys.map {|domain| "#{domain}.#{@name}".downcase }
47
- dupes = names.uniq.size != names.size || (@components.keys & names).any?
48
- raise "duplicate component domains not allowed" if dupes
50
+ raise "duplicate component domains not allowed" if dupes?(names, @components.keys)
51
+ raise "pubsub domains overlap component domains" if dupes?(names, @pubsubs.keys)
49
52
 
50
53
  options.each do |domain, password|
51
54
  raise 'component domain required' if (domain || '').to_s.strip.empty?
@@ -58,11 +61,43 @@ module Vines
58
61
  end
59
62
 
60
63
  def component?(domain)
61
- !!@components[domain]
64
+ !!@components[domain.to_s]
62
65
  end
63
66
 
64
67
  def password(domain)
65
- @components[domain]
68
+ @components[domain.to_s]
69
+ end
70
+
71
+ def pubsub(*domains)
72
+ domains.flatten!
73
+ raise 'define at least one pubsub domain' if domains.empty?
74
+ names = domains.map {|domain| "#{domain}.#{@name}".downcase }
75
+ raise "duplicate pubsub domains not allowed" if dupes?(names, @pubsubs.keys)
76
+ raise "pubsub domains overlap component domains" if dupes?(names, @components.keys)
77
+ domains.each do |domain|
78
+ raise 'pubsub domain required' if (domain || '').to_s.strip.empty?
79
+ name = "#{domain}.#{@name}".downcase
80
+ raise "pubsub domains must be one level below their host: #{name}" if domain.to_s.include?('.')
81
+ validate_domain(name)
82
+ @pubsubs[name] = PubSub.new(@config, name)
83
+ end
84
+ end
85
+
86
+ def pubsub?(domain)
87
+ @pubsubs.key?(domain.to_s)
88
+ end
89
+
90
+ # Unsubscribe this JID from all pubsub topics hosted at this virtual host.
91
+ # This should be called when the user's session ends via logout or
92
+ # disconnect.
93
+ def unsubscribe_pubsub(jid)
94
+ @pubsubs.values.each do |pubsub|
95
+ pubsub.unsubscribe_all(jid)
96
+ end
97
+ end
98
+
99
+ def disco_items
100
+ [@components.keys, @pubsubs.keys].flatten.sort
66
101
  end
67
102
 
68
103
  def private_storage(enabled)
@@ -75,7 +110,12 @@ module Vines
75
110
 
76
111
  private
77
112
 
78
- # Prevent domains in config files that won't form valid JID's.
113
+ # Return true if the arrays contain any duplicate items.
114
+ def dupes?(a, b)
115
+ a.uniq.size != a.size || b.uniq.size != b.size || (a & b).any?
116
+ end
117
+
118
+ # Prevent domains in config files that won't form valid JIDs.
79
119
  def validate_domain(name)
80
120
  jid = JID.new(name)
81
121
  raise "incorrect domain: #{name}" if jid.node || jid.resource
@@ -50,6 +50,11 @@ module Vines
50
50
  @settings[:max_resources_per_account]
51
51
  end
52
52
  end
53
+
54
+ def start
55
+ super
56
+ config.cluster.start if config.cluster?
57
+ end
53
58
  end
54
59
 
55
60
  class ServerPort < Port
@@ -0,0 +1,108 @@
1
+ # encoding: UTF-8
2
+
3
+ module Vines
4
+ class Config
5
+ # Provides the configuration DSL to conf/config.rb for pubsub subdomains and
6
+ # exposes the storage and notification systems that the pubsub stanzas need
7
+ # to process. This class hides the complexity of determining pubsub behavior
8
+ # in a standalone vs. clustered chat server environment from the stanzas.
9
+ class PubSub
10
+ def initialize(config, name)
11
+ @config, @name = config, name
12
+ @nodes = {}
13
+ end
14
+
15
+ def add_node(id)
16
+ if @config.cluster?
17
+ @config.cluster.add_pubsub_node(@name, id)
18
+ else
19
+ @nodes[id] ||= Set.new
20
+ end
21
+ end
22
+
23
+ def delete_node(id)
24
+ if @config.cluster?
25
+ @config.cluster.delete_pubsub_node(@name, id)
26
+ else
27
+ @nodes.delete(id)
28
+ end
29
+ end
30
+
31
+ def subscribe(node, jid)
32
+ return unless node?(node) && @config.allowed?(jid, @name)
33
+ if @config.cluster?
34
+ @config.cluster.subscribe_pubsub(@name, node, jid)
35
+ else
36
+ @nodes[node] << JID.new(jid)
37
+ end
38
+ end
39
+
40
+ def unsubscribe(node, jid)
41
+ return unless node?(node)
42
+ if @config.cluster?
43
+ @config.cluster.unsubscribe_pubsub(@name, node, jid)
44
+ else
45
+ @nodes[node].delete(JID.new(jid))
46
+ delete_node(node) if subscribers(node).empty?
47
+ end
48
+ end
49
+
50
+ def unsubscribe_all(jid)
51
+ if @config.cluster?
52
+ @config.cluster.unsubscribe_all_pubsub(@name, jid)
53
+ else
54
+ @nodes.keys.each do |node|
55
+ unsubscribe(node, jid)
56
+ end
57
+ end
58
+ end
59
+
60
+ def node?(node)
61
+ if @config.cluster?
62
+ @config.cluster.pubsub_node?(@name, node)
63
+ else
64
+ @nodes.key?(node)
65
+ end
66
+ end
67
+
68
+ def subscribed?(node, jid)
69
+ return false unless node?(node)
70
+ if @config.cluster?
71
+ @config.cluster.pubsub_subscribed?(@name, node, jid)
72
+ else
73
+ @nodes[node].include?(JID.new(jid))
74
+ end
75
+ end
76
+
77
+ def publish(node, stanza)
78
+ stanza['id'] = Kit.uuid
79
+ stanza['from'] = @name
80
+
81
+ local, remote = subscribers(node).partition {|jid| @config.local_jid?(jid) }
82
+
83
+ local.flat_map do |jid|
84
+ @config.router.connected_resources(jid, @name)
85
+ end.each do |recipient|
86
+ stanza['to'] = recipient.user.jid.to_s
87
+ recipient.write(stanza)
88
+ end
89
+
90
+ remote.each do |jid|
91
+ el = stanza.clone
92
+ el['to'] = jid.to_s
93
+ @config.router.route(el) rescue nil # ignore RemoteServerNotFound
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ def subscribers(node)
100
+ if @config.cluster?
101
+ @config.cluster.pubsub_subscribers(@name, node)
102
+ else
103
+ @nodes[node] || []
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end