em_apn_manager 0.0.1

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,59 @@
1
+ # encoding: UTF-8
2
+
3
+ # Mock Apple push server for testing.
4
+ module EventMachine
5
+ module ApnManager
6
+ module ApnServer
7
+ def post_init
8
+ EM::ApnManager.logger.info("Received a new connection")
9
+ @data = ""
10
+
11
+ start_tls(
12
+ :cert_chain_file => ENV["APN_CERT"],
13
+ :private_key_file => ENV["APN_CERT"],
14
+ :verify_peer => false
15
+ )
16
+ end
17
+
18
+ def ssl_handshake_completed
19
+ EM::ApnManager.logger.info("SSL handshake completed")
20
+ end
21
+
22
+ def receive_data(data)
23
+ @data << data
24
+
25
+ # Try to extract the payload header
26
+ headers = @data.unpack("cNNnH64n")
27
+ return if headers.last.nil?
28
+
29
+ # Try to grab the payload
30
+ payload_size = headers.last
31
+ payload = @data[45, payload_size]
32
+ return if payload.length != payload_size
33
+
34
+ @data = @data[45 + payload_size, -1] || ""
35
+
36
+ process(headers, payload)
37
+ end
38
+
39
+ def process(headers, payload)
40
+ message = "APN RECV #{headers[4]} #{payload}"
41
+ EM::ApnManager.logger.info(message)
42
+
43
+ args = Yajl::Parser.parse(payload)
44
+
45
+ # If the alert is 'DISCONNECT', then we fake a bad payload by replying
46
+ # with an error and disconnecting.
47
+ if args["aps"]["alert"] == "DISCONNECT"
48
+ EM::ApnManager.logger.info("Disconnecting")
49
+ send_data([8, 1, 0].pack("ccN"))
50
+ close_connection_after_writing
51
+ end
52
+ end
53
+
54
+ def unbind
55
+ EM::ApnManager.logger.info("Connection closed")
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,69 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'thor'
4
+ require 'redis'
5
+ require 'yaml'
6
+ require 'yajl'
7
+ require 'yajl/json_gem'
8
+ require 'em_apn_manager'
9
+ require "em_apn_manager/manager"
10
+ require "em_apn_manager/apn_server"
11
+
12
+ ENV["APN_CERT"] ||= File.join(File.dirname(__FILE__), "..", "..", "certs", "cert.pem")
13
+
14
+ $apn_manager_redis = nil
15
+
16
+ module EventMachine
17
+ module ApnManager
18
+ class CLI < Thor
19
+ class_option :config, :aliases => ["-c"], :type => :string
20
+ class_option :environment, :aliases => ["-e"], :type => :string
21
+
22
+ def initialize(args = [], opts = [], config = {})
23
+ super(args, opts, config)
24
+
25
+ # Read config option, or use default config yml
26
+ config_path = options[:config] || File.join(".", "config", "em_apn_manager.yml")
27
+ if config_path && File.exists?(config_path)
28
+ EM::ApnManager.config = Thor::CoreExt::HashWithIndifferentAccess.new(YAML.load_file(config_path))
29
+ else
30
+ raise "No config file is specified or specified config file doesn't exist."
31
+ end
32
+
33
+ # read the environment var.
34
+ @environment = "test"
35
+ @environment = options[:environment] if %w{test development production}.include? options[:environment]
36
+
37
+ # create redis connection
38
+ $apn_manager_redis = Redis.new EM::ApnManager.config[@environment] || { host:"127.0.0.1", port:6379 }
39
+ end
40
+
41
+ desc "server", "Start manager server."
42
+ # option :daemon, :aliases => ["-d"], :type => :boolean
43
+ # option :pid_file, :aliases => ["-p"], :type => :string
44
+ def server
45
+ EM::ApnManager.logger.info("Starting APN Manager")
46
+ EM.run { EM::ApnManager::Manager.run env: @environment }
47
+ end
48
+
49
+ ### For Testing ##################################################
50
+ desc "push_test_message", "Push test messages to server."
51
+ # if message is DISCONNECT, mock apns will disconnect.
52
+ def push_test_message
53
+ 10.times do |i|
54
+ EM::ApnManager.push_notification({
55
+ cert: File.read(ENV["APN_CERT"]),
56
+ token: ["0F93C49EAAF3544B5218D2BAE893608C515F69B445279AB2B17511C37046C52B","D42A6795D0C6C0E5F3CC762F905C3654D2A07E72D64CDEC1E2F74AC43C4CC440"].sample,
57
+ message: "Hahahaha I am going to spam you. #{i}-#{rand * 100}"
58
+ })
59
+ end
60
+ end
61
+
62
+ desc "mock_apn_server", "Start a mock apple APNS Server."
63
+ def mock_apn_server
64
+ EM::ApnManager.logger.info("Starting Mock APN Server")
65
+ EM.run { EM.start_server("127.0.0.1", 2195, EM::ApnManager::ApnServer) }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,75 @@
1
+ # encoding: UTF-8
2
+
3
+ require "em_apn_manager/connection"
4
+
5
+ module EventMachine
6
+ module ApnManager
7
+ class Client
8
+ SANDBOX_GATEWAY = "gateway.sandbox.push.apple.com"
9
+ PRODUCTION_GATEWAY = "gateway.push.apple.com"
10
+ TEST_GATEWAY = "127.0.0.1"
11
+ PORT = 2195
12
+
13
+ attr_reader :gateway, :port, :cert, :connection, :error_callback, :close_callback, :open_callback
14
+
15
+ # A convenience method for creating and connecting.
16
+ def self.connect(options = {})
17
+ new(options).tap do |client|
18
+ client.connect
19
+ end
20
+ end
21
+
22
+ def initialize(options = {})
23
+ @cert = options[:cert] || ENV["APN_CERT"]
24
+ @port = options[:port] || PORT
25
+ @environment = options[:env]
26
+ @gateway = options[:gateway] || ENV["APN_GATEWAY"]
27
+ @gateway ||= case @environment
28
+ when "test"
29
+ TEST_GATEWAY
30
+ when "development"
31
+ SANDBOX_GATEWAY
32
+ when "production"
33
+ PRODUCTION_GATEWAY
34
+ else
35
+ TEST_GATEWAY
36
+ end
37
+ @connection = nil
38
+ end
39
+
40
+ def connect
41
+ @connection = EM.connect(gateway, port, Connection, self)
42
+ end
43
+
44
+ def connected?
45
+ !connection.nil? && !connection.disconnected?
46
+ end
47
+
48
+ def deliver(notification)
49
+ if !connected?
50
+ puts "No connection."
51
+ return
52
+ end
53
+ notification.validate!
54
+ log(notification)
55
+ connection.send_data(notification.data)
56
+ end
57
+
58
+ def on_error(&block)
59
+ @error_callback = block
60
+ end
61
+
62
+ def on_close(&block)
63
+ @close_callback = block
64
+ end
65
+
66
+ def on_open(&block)
67
+ @open_callback = block
68
+ end
69
+
70
+ def log(notification)
71
+ EM::ApnManager.logger.info("TOKEN=#{notification.token} PAYLOAD=#{notification.payload.inspect}")
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,51 @@
1
+ # encoding: UTF-8
2
+
3
+ require "em_apn_manager/error_response"
4
+
5
+ module EventMachine
6
+ module ApnManager
7
+ class Connection < EM::Connection
8
+ attr_reader :client
9
+
10
+ def initialize(*args)
11
+ super
12
+ @client = args.last
13
+ @disconnected = false
14
+ end
15
+
16
+ def disconnected?
17
+ @disconnected
18
+ end
19
+
20
+ def post_init
21
+ EM::ApnManager.logger.info("Connecting...")
22
+
23
+ start_tls(
24
+ :private_key_file => client.cert,
25
+ :cert_chain_file => client.cert,
26
+ :verify_peer => false
27
+ )
28
+ end
29
+
30
+ def connection_completed
31
+ EM::ApnManager.logger.info("Connection completed")
32
+
33
+ client.open_callback.call if client.open_callback
34
+ end
35
+
36
+ def receive_data(data)
37
+ data_array = data.unpack("ccN")
38
+ error_response = ErrorResponse.new(*data_array)
39
+ EM::ApnManager.logger.warn(error_response.to_s)
40
+ client.error_callback.call(error_response) if client.error_callback
41
+ end
42
+
43
+ def unbind
44
+ EM::ApnManager.logger.info("Connection closed")
45
+
46
+ @disconnected = true
47
+ client.close_callback.call if client.close_callback
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,9 @@
1
+ module EventMachine
2
+ module ApnManager
3
+ class Engine < ::Rails::Engine
4
+ generators do
5
+ require 'em_apn_manager/generators/install.rb'
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # encoding: UTF-8
2
+
3
+ module EventMachine
4
+ module ApnManager
5
+ class ErrorResponse
6
+ DESCRIPTION = {
7
+ 0 => "No errors encountered",
8
+ 1 => "Processing error",
9
+ 2 => "Missing device token",
10
+ 3 => "Missing topic",
11
+ 4 => "Missing payload",
12
+ 5 => "Invalid token size",
13
+ 6 => "Invalid topic size",
14
+ 7 => "Invalid payload size",
15
+ 8 => "Invalid token",
16
+ 255 => "None (unknown)"
17
+ }
18
+
19
+ attr_reader :command, :status_code, :identifier
20
+
21
+ def initialize(command, status_code, identifier)
22
+ @command = command
23
+ @status_code = status_code
24
+ @identifier = identifier
25
+ end
26
+
27
+ def to_s
28
+ "CODE=#{@status_code} ID=#{@identifier} DESC=#{description}"
29
+ end
30
+
31
+ def description
32
+ DESCRIPTION[@status_code] || "Missing description"
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: UTF-8
2
+
3
+ module EventMachine
4
+ module ApnManager
5
+ class Install < Rails::Generators::Base
6
+ desc "Install EventMachine APN Manager."
7
+ source_root File.expand_path('../templates', __FILE__)
8
+ def create_config
9
+ say "Create Configure file to project...", :yellow
10
+ copy_file "em_apn_manager.yml", "config/em_apn_manager.yml"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ production:
2
+ host: localhost
3
+ port: 6379
4
+ db: YOUR_PRODUCTION_REDIS
5
+ password: PASSWORD
6
+
7
+ development:
8
+ host: localhost
9
+ port: 6379
10
+
11
+ test:
12
+ host: localhost
13
+ port: 6379
@@ -0,0 +1,15 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'logger'
4
+
5
+ module EventMachine
6
+ module ApnManager
7
+ def self.logger
8
+ @logger ||= Logger.new(STDOUT)
9
+ end
10
+
11
+ def self.logger=(new_logger)
12
+ @logger = new_logger
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,71 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'base64'
4
+ require 'yajl'
5
+ require 'yajl/json_gem'
6
+ require 'em-hiredis'
7
+ require "em_apn_manager/client"
8
+ require "em_apn_manager/notification"
9
+
10
+ module EventMachine
11
+ module ApnManager
12
+ class Manager
13
+ $connection_pool = {}
14
+
15
+ def self.run options = {}
16
+ self.new.tap do |manager|
17
+ manager.run options
18
+ end
19
+ end # end of run
20
+
21
+ # options = {gateway, port}
22
+ def run options = {}
23
+ @redis = EM::Hiredis.connect
24
+
25
+ ### launch a new connect to apple when detected any pushs.
26
+ @redis.pubsub.subscribe('push-notification') do |msg|
27
+ msg_hash = Yajl::Parser.parse(msg) # might be some wrong json
28
+ # save the cert to local first, since the start_tls read from file.
29
+ cert_filename = save_cert_to_file msg_hash["cert"]
30
+ # cert filename is a key for connection pool
31
+ client = $connection_pool[cert_filename]
32
+
33
+ ### Create client connection if doesn't exist in pool.
34
+ if client.nil?
35
+ client = EM::ApnManager::Client.new(options.merge!({cert: cert_filename}))
36
+ # Store the connection to pool
37
+ $connection_pool[cert_filename] = client
38
+ end
39
+
40
+ ### send message directly if connection was connected.
41
+ ### TODO you can bind on_close, on_open, on_error events.
42
+ notification = EM::ApnManager::Notification.new(msg_hash["token"], :alert => msg_hash["message"])
43
+ if client.connected?
44
+ client.deliver(notification)
45
+ else
46
+ # if connection not connected, set callback block to deliver notification, and connect to apple server.
47
+ client.on_open do
48
+ client.deliver(notification)
49
+ end
50
+ client.connect
51
+ end
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def save_cert_to_file cert_content
58
+ # TODO, should store Rails.root/tmp/certs and this folder should be protected.
59
+ FileUtils.mkdir_p "certs"
60
+ filename = Base64.encode64(cert_content)[0..50]
61
+ filename = File.join "certs", filename
62
+ return filename if File.exist?(filename)
63
+
64
+ File.open filename, "w+" do |f|
65
+ f.write cert_content
66
+ end
67
+ filename
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,58 @@
1
+ # encoding: UTF-8
2
+
3
+ module EventMachine
4
+ module ApnManager
5
+ class Notification
6
+ DATA_MAX_BYTES = 256
7
+
8
+ class PayloadTooLarge < StandardError;end
9
+
10
+ attr_reader :token
11
+ attr_accessor :identifier, :expiry
12
+
13
+ def initialize(token, aps = {}, custom = {}, options = {})
14
+ raise "Bad push token: #{token}" if token.nil? || (token.length != 64)
15
+
16
+ @token = token
17
+ @aps = aps
18
+ @custom = custom
19
+
20
+ self.identifier = options[:identifier] if options[:identifier]
21
+ self.expiry = options[:expiry] if options[:expiry]
22
+ end
23
+
24
+ def payload
25
+ Yajl::Encoder.encode(@custom.merge(:aps => @aps))
26
+ end
27
+
28
+ # Documentation about this format is here:
29
+ # http://developer.apple.com/library/ios/#documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingWIthAPS/CommunicatingWIthAPS.html
30
+ def data
31
+ identifier = @identifier || 0
32
+ expiry = @expiry || 0
33
+ size = [payload].pack("a*").size
34
+ data_array = [1, identifier, expiry, 32, token, size, payload]
35
+ data_array.pack("cNNnH*na*")
36
+ end
37
+
38
+ def validate!
39
+ if data.size > DATA_MAX_BYTES
40
+ error = "max is #{DATA_MAX_BYTES} bytes, but got #{data.size}: #{payload.inspect}"
41
+ raise PayloadTooLarge.new(error)
42
+ else
43
+ true
44
+ end
45
+ end
46
+
47
+ def identifier=(new_identifier)
48
+ @identifier = new_identifier.to_i
49
+ end
50
+
51
+ def truncate_alert!
52
+ while data.size > DATA_MAX_BYTES && !@aps["alert"].nil? && @aps["alert"].size > 0
53
+ @aps["alert"] = @aps["alert"][0..-2]
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end