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.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +1 -0
- data/.travis.yml +8 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +101 -0
- data/LICENSE +20 -0
- data/README.md +173 -0
- data/Rakefile +11 -0
- data/VERSION +1 -0
- data/bin/sinapse +10 -0
- data/certs/ysbaddaden.pem +21 -0
- data/config/rainbows.rb +3 -0
- data/ext/mkrf_conf.rb +22 -0
- data/lib/sinapse.rb +42 -0
- data/lib/sinapse/authentication.rb +44 -0
- data/lib/sinapse/channels.rb +51 -0
- data/lib/sinapse/config.rb +31 -0
- data/lib/sinapse/cross_origin_resource_sharing.rb +79 -0
- data/lib/sinapse/keep_alive.rb +25 -0
- data/lib/sinapse/publishable.rb +26 -0
- data/lib/sinapse/server.rb +113 -0
- data/lib/sinapse/version.rb +10 -0
- data/sinapse.gemspec +33 -0
- data/test/authentication_test.rb +69 -0
- data/test/channels_test.rb +101 -0
- data/test/cross_origin_resource_sharing_test.rb +167 -0
- data/test/publishable_test.rb +32 -0
- data/test/server_test.rb +189 -0
- data/test/support/event_source.rb +70 -0
- data/test/support/goliath.rb +13 -0
- data/test/support/redis.rb +32 -0
- data/test/support/timeout.rb +8 -0
- data/test/test_helper.rb +31 -0
- metadata +262 -0
- metadata.gz.sig +0 -0
|
@@ -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
|
data/sinapse.gemspec
ADDED
|
@@ -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
|