sinapse 0.1.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.
@@ -0,0 +1,44 @@
1
+ require 'securerandom'
2
+
3
+ module Sinapse
4
+ def self.generate_token
5
+ SecureRandom.urlsafe_base64(64)
6
+ end
7
+
8
+ # TODO: #get to return the token (if any)
9
+ class Authentication < Struct.new(:record)
10
+ def reset
11
+ clear
12
+ generate
13
+ end
14
+
15
+ def generate
16
+ Sinapse.redis do |redis|
17
+ loop do
18
+ token = Sinapse.generate_token
19
+ if redis.setnx(token_key(token), record.to_param)
20
+ redis.set(key, token)
21
+ return token
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ def clear
28
+ Sinapse.redis do |redis|
29
+ if token = redis.get(key)
30
+ redis.del(token_key(token))
31
+ redis.del(key)
32
+ end
33
+ end
34
+ end
35
+
36
+ def token_key(token)
37
+ "sinapse:tokens:#{token}"
38
+ end
39
+
40
+ def key
41
+ "sinapse:#{record.class.name}:#{record.to_param}"
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,51 @@
1
+ module Sinapse
2
+ # TODO: #access_token to return the current user token (or generate one if missing)
3
+ class Channels < Struct.new(:record)
4
+ def auth
5
+ @auth ||= Authentication.new(record)
6
+ end
7
+
8
+ def channels
9
+ Sinapse.redis { |redis| redis.smembers(key) }
10
+ end
11
+
12
+ def has_channel?(channel)
13
+ Sinapse.redis { |redis| redis.sismember(key, channel_for(channel)) }
14
+ end
15
+
16
+ def add_channel(channel)
17
+ Sinapse.redis do |redis|
18
+ redis.sadd(key, channel_for(channel))
19
+ redis.publish(key(:add), channel_for(channel))
20
+ end
21
+ end
22
+
23
+ def remove_channel(channel)
24
+ Sinapse.redis do |redis|
25
+ redis.srem(key, channel_for(channel))
26
+ redis.publish(key(:remove), channel_for(channel))
27
+ end
28
+ end
29
+
30
+ # Removes all channels at once.
31
+ def clear
32
+ channels.each { |channel| remove_channel(channel) }
33
+ end
34
+
35
+ # Removes all channels and clears authentication.
36
+ def destroy
37
+ channels.each { |channel| remove_channel(channel) }
38
+ auth.clear
39
+ end
40
+
41
+ def channel_for(record)
42
+ record.is_a?(String) ? record : record.sinapse_channel
43
+ end
44
+
45
+ def key(extra = nil)
46
+ key = "sinapse:channels:#{record.to_param}"
47
+ key += ":#{extra}" if extra
48
+ key
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,31 @@
1
+ module Sinapse
2
+ module Config
3
+ extend self
4
+
5
+ def retry
6
+ default(:SINAPSE_RETRY, 5).to_i * 1000
7
+ end
8
+
9
+ def keep_alive
10
+ default(:SINAPSE_KEEP_ALIVE, 15).to_i
11
+ end
12
+
13
+ def cors_origin
14
+ default(:SINAPSE_CORS_ORIGIN, '*')
15
+ end
16
+
17
+ def channel_event
18
+ !ENV["SINAPSE_CHANNEL_EVENT"].nil?
19
+ end
20
+
21
+ private
22
+
23
+ def default(name, default_value)
24
+ if ENV.has_key?(name.to_s)
25
+ ENV[name.to_s]
26
+ else
27
+ default_value.to_s
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,79 @@
1
+ module Sinapse
2
+ module Rack
3
+ class CrossOriginResourceSharing
4
+ include Goliath::Rack::AsyncMiddleware
5
+
6
+ def initialize(app, options = {})
7
+ super(app)
8
+
9
+ @origin = options[:origin] || '*'
10
+ @methods = options[:methods] || %w(GET POST)
11
+ @max_age = options[:max_age]
12
+ end
13
+
14
+ def call(env)
15
+ env['HTTP_ORIGIN'] ||= env['HTTP_X_ORIGIN']
16
+ env['cors.headers'] = nil
17
+
18
+ if env['HTTP_ORIGIN']
19
+ if env['REQUEST_METHOD'] == 'OPTIONS' && env['HTTP_ACCESS_CONTROL_REQUEST_METHOD']
20
+ return [200, preflight_headers(env), ''] if allowed?(env)
21
+ return [400, {}, '']
22
+ end
23
+
24
+ if allowed_origin?(env['HTTP_ORIGIN']) && allowed_method?(env['REQUEST_METHOD'])
25
+ env['cors.headers'] = response_headers(env)
26
+ end
27
+ end
28
+
29
+ super(env)
30
+ end
31
+
32
+ def post_process(env, status, headers, body)
33
+ augmented_headers = headers.merge(env['cors.headers']) if env['cors.headers']
34
+ [status, augmented_headers || headers, body]
35
+ end
36
+
37
+ private
38
+
39
+ def allowed?(env)
40
+ allowed_origin?(env['HTTP_ORIGIN']) &&
41
+ allowed_method?(env['HTTP_ACCESS_CONTROL_REQUEST_METHOD'])
42
+ end
43
+
44
+ def allowed_origin?(origin)
45
+ case @origin
46
+ when Regexp
47
+ @origin =~ origin
48
+ when '*'
49
+ true
50
+ else
51
+ origin == @origin || origin =~ %r(^https?://#{@origin})
52
+ end
53
+ end
54
+
55
+ def allowed_method?(method)
56
+ methods.include?(method.to_s.upcase)
57
+ end
58
+
59
+ def methods
60
+ @methods.map { |m| m.to_s.upcase }
61
+ end
62
+
63
+ def preflight_headers(env)
64
+ response_headers(env).merge(
65
+ 'Content-Type' => 'text/plain',
66
+ 'Access-Control-Allow-Headers' => env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'],
67
+ )
68
+ end
69
+
70
+ def response_headers(env)
71
+ {
72
+ 'Access-Control-Allow-Origin' => env['HTTP_ORIGIN'],
73
+ 'Access-Control-Allow-Methods' => methods.join(', '),
74
+ 'Access-Control-Max-Age' => @max_age.to_s
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,25 @@
1
+ module Sinapse
2
+ class KeepAlive
3
+ def initialize
4
+ @queue = []
5
+ end
6
+
7
+ def <<(env)
8
+ @queue << env
9
+ @timer = start if @queue.size == 1
10
+ end
11
+
12
+ def delete(env)
13
+ @queue.delete(env)
14
+ @timer.cancel if @timer && @queue.size == 0
15
+ end
16
+
17
+ protected
18
+
19
+ def start
20
+ EM.add_periodic_timer(Config.keep_alive) do
21
+ @queue.each { |env| env.chunked_stream_send ":\n" }
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,26 @@
1
+ require 'active_support/core_ext/string/inflections'
2
+ require 'msgpack'
3
+
4
+ module Sinapse
5
+ module Publishable
6
+ def self.included(klass)
7
+ klass.__send__ :alias_method, :publish, :sinapse_publish unless klass.respond_to?(:publish)
8
+ end
9
+
10
+ def sinapse_publish(message, options = nil)
11
+ data = Publishable.pack(message, options)
12
+ Sinapse.redis { |redis| redis.publish(sinapse_channel, data) }
13
+ end
14
+
15
+ def sinapse_channel
16
+ [self.class.name.underscore.singularize, self.to_param].join(':')
17
+ end
18
+
19
+ private
20
+
21
+ def self.pack(message, options)
22
+ data = options.is_a?(Hash) && options[:event] ? [options[:event].to_s, message.to_s] : message.to_s
23
+ MessagePack.pack(data)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,113 @@
1
+ require 'goliath'
2
+ require 'sinapse/config'
3
+ require 'sinapse/keep_alive'
4
+ require 'sinapse/cross_origin_resource_sharing'
5
+ require 'msgpack'
6
+
7
+ module Sinapse
8
+ class Server < Goliath::API
9
+ use Sinapse::Rack::CrossOriginResourceSharing, origin: Config.cors_origin
10
+ use Goliath::Rack::Params
11
+ use Goliath::Rack::Heartbeat # respond to /status with 200, OK (monitoring, etc)
12
+ use Goliath::Rack::Validation::RequestMethod, %w(GET POST)
13
+ use Goliath::Rack::Validation::RequiredParam, { key: 'access_token' }
14
+
15
+ def keep_alive
16
+ @keep_alive ||= KeepAlive.new
17
+ end
18
+
19
+ def on_close(env)
20
+ close_redis(env['redis']) if env['redis']
21
+ keep_alive.delete(env)
22
+ end
23
+
24
+ def response(env)
25
+ env['redis'] = Redis.new(:driver => :synchrony, :url => Sinapse.config[:url])
26
+
27
+ user, channels = authenticate(env)
28
+ return [401, {}, []] if user.nil? || channels.empty?
29
+
30
+ EM.next_tick do
31
+ sse(env, :ok, :authentication, retry: Config.retry)
32
+ subscribe(env, user, channels)
33
+ keep_alive << env
34
+ end
35
+
36
+ chunked_streaming_response(200, response_headers(env))
37
+ end
38
+
39
+ private
40
+
41
+ def authenticate(env)
42
+ user = env['redis'].get("sinapse:tokens:#{params['access_token']}")
43
+ if user
44
+ channels = env['redis'].smembers("sinapse:channels:#{user}")
45
+ [user, channels]
46
+ end
47
+ end
48
+
49
+ def subscribe(env, user, channels)
50
+ EM.synchrony do
51
+ env['redis'].psubscribe("sinapse:channels:#{user}:*") do |on|
52
+ on.psubscribe do
53
+ env['redis'].subscribe(*channels)
54
+ end
55
+
56
+ on.pmessage do |_, channel, message|
57
+ update_subscriptions(env, message, channel)
58
+ end
59
+
60
+ on.message do |channel, data|
61
+ event, message = unpack(channel, data)
62
+ sse(env, message, event)
63
+ end
64
+ end
65
+ env['redis'].quit
66
+ end
67
+ end
68
+
69
+ def update_subscriptions(env, message, channel)
70
+ return env['redis'].subscribe(message) if channel.end_with?(':add')
71
+ return env['redis'].unsubscribe(message) if channel.end_with?(':remove')
72
+ end
73
+
74
+ def unpack(channel, data)
75
+ message = MessagePack.unpack(data)
76
+ if message.is_a?(Array)
77
+ message
78
+ else
79
+ event = Config.channel_event ? channel : nil
80
+ [event, message]
81
+ end
82
+ end
83
+
84
+ def sse(env, data, event = nil, options = {})
85
+ message = []
86
+ message << "retry: %d" % options[:retry] if options[:retry]
87
+ message << "id: %d" % options[:id] if options[:id]
88
+ message << "event: %s" % event if event
89
+ message << "data: %s" % data.to_s.gsub(/\n/, "\ndata: ")
90
+ env.chunked_stream_send message.join("\n") + "\n\n"
91
+ end
92
+
93
+ def close_redis(redis)
94
+ if redis.subscribed?
95
+ redis.unsubscribe
96
+ else
97
+ redis.quit
98
+ end
99
+ end
100
+
101
+ def response_headers(env)
102
+ headers = {
103
+ 'Connection' => 'close',
104
+ 'Content-Type' => 'text/event-stream'
105
+ }
106
+ if env['cors.headers']
107
+ headers.merge(env['cors.headers'])
108
+ else
109
+ headers
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,10 @@
1
+ module Sinapse
2
+ def self.version
3
+ Gem::Version.new File.read(File.expand_path('../../../VERSION', __FILE__))
4
+ end
5
+
6
+ module VERSION
7
+ MAJOR, MINOR, TINY, PRE = Sinapse.version.segments
8
+ STRING = Sinapse.version.to_s
9
+ end
10
+ end
@@ -0,0 +1,33 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/sinapse/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Julien Portalier"]
6
+ gem.email = ["julien@portalier.com"]
7
+ gem.description = gem.summary = "An EventSource push service for Ruby"
8
+ gem.homepage = "http://github.com/ysbaddaden/sinapse"
9
+ gem.license = "MIT"
10
+
11
+ gem.executables = ['sinapse']
12
+ gem.files = `git ls-files | grep -Ev '^example'`.split("\n")
13
+ gem.test_files = `git ls-files -- test/*`.split("\n")
14
+ gem.name = "sinapse"
15
+ gem.require_paths = ["lib"]
16
+ gem.version = Sinapse::VERSION::STRING
17
+
18
+ gem.cert_chain = ['certs/ysbaddaden.pem']
19
+ gem.signing_key = File.expand_path('~/.ssh/gem-private_key.pem') if $0 =~ /gem\z/
20
+
21
+ gem.add_dependency 'goliath', '>= 1.0.3'
22
+ gem.add_dependency 'redis', '>= 3.0.6'
23
+ gem.add_dependency 'hiredis'
24
+ gem.add_dependency 'connection_pool'
25
+ gem.add_dependency 'msgpack', '>= 0.5.0'
26
+ gem.add_dependency 'activesupport', '>= 3.0.0'
27
+
28
+ gem.add_development_dependency 'bundler'
29
+ gem.add_development_dependency 'rake'
30
+ gem.add_development_dependency 'em-http-request'
31
+ gem.add_development_dependency 'minitest', '>= 5.2.0'
32
+ gem.add_development_dependency 'minitest-reporters'
33
+ end
@@ -0,0 +1,69 @@
1
+ require 'test_helper'
2
+
3
+ describe "Sinapse::Authentication" do
4
+ after do
5
+ Sinapse.redis do |redis|
6
+ redis.keys('sinapse:*').each { |key| redis.del(key) }
7
+ end
8
+ end
9
+
10
+ let(:user) { User.new(rand(1..100)) }
11
+ let(:auth) { user.sinapse.auth }
12
+
13
+ it "key" do
14
+ assert_equal "sinapse:User:1", User.new(1).sinapse.auth.key
15
+ assert_equal "sinapse:Admin:456", Admin.new(456).sinapse.auth.key
16
+ end
17
+
18
+ describe "generate" do
19
+ it "must generate token" do
20
+ Sinapse.stub(:generate_token, 'valid') do
21
+ assert_equal 'valid', auth.generate
22
+
23
+ Sinapse.redis do |redis|
24
+ assert_equal 'valid', redis.get(auth.key)
25
+ assert_equal user.to_param, redis.get(auth.token_key('valid'))
26
+ end
27
+ end
28
+ end
29
+
30
+ it "won't use an existing token" do
31
+ tokens = ['first', 'first', 'second']
32
+
33
+ Sinapse.stub(:generate_token, lambda { tokens.shift }) do
34
+ User.new(2).sinapse.auth.generate
35
+ assert_equal 'second', auth.generate
36
+
37
+ Sinapse.redis do |redis|
38
+ assert_equal 'second', redis.get(auth.key)
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ it "clear" do
45
+ Sinapse.stub(:generate_token, 'a1b2c3d4e5f6') do
46
+ auth.generate
47
+ auth.clear
48
+
49
+ Sinapse.redis do |redis|
50
+ assert_nil redis.get(auth.key)
51
+ assert_nil redis.get(auth.token_key('a1b2c3d4e5f6'))
52
+ end
53
+ end
54
+ end
55
+
56
+ it "reset" do
57
+ tokens = ['first', 'second']
58
+
59
+ Sinapse.stub(:generate_token, lambda { tokens.shift }) do
60
+ auth.generate
61
+ auth.reset
62
+
63
+ Sinapse.redis do |redis|
64
+ assert_equal 'second', redis.get(auth.key)
65
+ assert_equal user.to_param, redis.get(auth.token_key('second'))
66
+ end
67
+ end
68
+ end
69
+ end