vines 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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