pushlet 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,21 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ coverage
6
+ InstalledFiles
7
+ lib/bundler/man
8
+ pkg
9
+ rdoc
10
+ spec/reports
11
+ test/tmp
12
+ test/version_tmp
13
+ tmp
14
+ .DS_Store
15
+
16
+ # YARD artifacts
17
+ .yardoc
18
+ _yardoc
19
+ doc/
20
+ config.ru
21
+ Gemfile.lock
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ source :rubygems
2
+ gemspec
3
+
4
+ platform :jruby do
5
+ gem 'jruby-openssl'
6
+ end
7
+
8
+ group :test do
9
+ platform :mri do
10
+ gem 'simplecov', require: nil
11
+ end
12
+ end
@@ -0,0 +1,187 @@
1
+ Pushlet
2
+ =======
3
+
4
+ Pushlet is a simple, stateless HTTP API for interacting with Apple and Google's push services for mobile devices. It's goal is to simplify your push notifications by providing a simple SoA API that does this one thing, and does it well.
5
+
6
+ Pushlet integrates [Grocer](http://github.com/highgroove/grocer) for APNS, and is designed to be thread safe.
7
+
8
+ Because it is containerized and requires no data store, it can be easily put on a hosted service like AppFog or Heroku.
9
+
10
+ ## Requirements
11
+
12
+ * Ruby 1.9, JRuby 1.7, Rubinius 2.0 or later.
13
+
14
+ ## Installation and Configuration
15
+
16
+ First, install the gem via Rubygems:
17
+
18
+ gem install pushlet
19
+
20
+ Then, setup a `config.ru` with the following. Setup an HTTP Auth so that you can control access (we provide a simple helper for you that returns JSON error messages, but you can use any Rack Middleware you like):
21
+
22
+ require 'pushlet'
23
+
24
+ map '/' do
25
+ Pushlet::API.http_basic_auth 'YOUR_USERNAME', 'YOUR_PASSWORD'
26
+ run Pushlet::API
27
+ end
28
+
29
+ Pushlet is **thread optimized**, so use a threaded web server. I highly recommend using [Puma](http://puma.io), which is designed for threaded applications. You can add it by putting `gem 'puma'` in your Gemfile:
30
+
31
+ $ bundle exec puma
32
+
33
+ You can also use `rackup`:
34
+
35
+ $ bundle exec rackup -s puma
36
+
37
+ If using Heroku, setup a `Procfile` to make it start with Puma by default:
38
+
39
+ web: bundle exec puma -p $PORT
40
+
41
+ ## Apple Push Notifications (APNS)
42
+
43
+ Sending push notifications to apple is a two step process. First, you need to register the application via the `register_application` route:
44
+
45
+ $ curl http://127.0.0.1:9292/register_application -F "apns_p12=@/PATH/TO/YOUR/CERT.p12" -F "application_id=YOUR_APPLICATION_NAME"
46
+
47
+ Now you can send push notifications via the `send_notification` route, and the socket connection for that application will handle them:
48
+
49
+ $ curl http://127.0.0.1:9292/send_notification -d "device_token=YOUR_DEVICE_TOKEN&alert=YOUR+TEXT+MESSAGE&application_id=YOUR_APPLICATION_NAME"
50
+
51
+ ## Google Cloud Messaging (GCM)
52
+
53
+ Google does not require a stateful socket connection, because it uses HTTPS. It's simple enough to implement the protocol that this project doesn't add a lot of value for this, but we've included it for convenience.
54
+
55
+ GCM does not have a concept of a "message" field. You are given a data input that can be JSON, and you need to program your application to send a message based on which column you send. For example, if your pre-defined field is `text`, encode with something like `Rack::Utils.escape({text: 'hello'}.to_json)`, and then send this way:
56
+
57
+ curl http://127.0.0.1:9292/send_notification -d "device_token=YOUR_GCM_DEVICE_KEY&gcm_api_key=YOUR_GCM_API_KEY&data=%7B%22text%22%3A%22hello%22%7D"
58
+
59
+ ## POST /application_feedback
60
+
61
+ Returns the feedback information from Apple. Not required for GCM.
62
+
63
+ ### Arguments
64
+
65
+ * application\_id - required
66
+
67
+ ### Example Response
68
+
69
+ {
70
+ "response": [
71
+ {
72
+ "device_token": "abcd",
73
+ "timestamp": "2012-11-27T22:24:07Z"
74
+ },
75
+ {
76
+ "device_token": "efgh",
77
+ "timestamp": "2012-11-27T22:24:07Z"
78
+ }
79
+ ]
80
+ }
81
+
82
+ ### Possible errors
83
+
84
+ {
85
+ "error": "missing_application_id",
86
+ "message": "an application_id is required for this call"
87
+ }
88
+
89
+ {
90
+ "error": "application_not_found",
91
+ "message": "no application found for the provided application_id"
92
+ }
93
+
94
+ ## POST /register_application
95
+
96
+ Registers the APNS application with a certificate. This will create a socket connection when the first message is sent, providing a persistent connection to the Apple push servers.
97
+
98
+ * application_id - a unique identifier for this application. If none is provided, a UUID-based one will be generated and returned to you, and you will need to store it somewhere to use the connection.
99
+ * apns\_p12 - Apple certificate in P12 format. Either this or apns\_pem are required.
100
+ * apns\_pem - Apple certificate in PEM format. Either this or apns\_p12 are required.
101
+ * apns_mode - toggles whether to use sandbox mode or not. If set to "development", the sandbox is used. Defaults to production.
102
+ * apns\_p12\_pass - password for P12. Optional.
103
+
104
+ ### Example Response
105
+
106
+ {
107
+ "application_id": "id_you_supplied_or_uuid"
108
+ }
109
+
110
+ ### Possible errors
111
+
112
+ {
113
+ "error": "missing_apns_certificate",
114
+ "message": "an apns_p12 or apns_pem certificate is required for this call"
115
+ }
116
+
117
+ {
118
+ "error": "invalid_apns_p12",
119
+ "message": "could not process apns_p12 certificate, check that it is in p12 format and is valid"
120
+ }
121
+
122
+
123
+ ## POST /send_notification
124
+
125
+ Sends a message to either the GCM REST service or the APNS application socket.
126
+
127
+ * type - "apns" or "gcm". Required.
128
+ * application\_id - application\_id provided for registered APNS application. Either this or gcm\_api\_key are required.
129
+ * gcm\_api\_key - GCM secret key from Google. Either this or application_id are required.
130
+ * device\_token - the device push token provided by the phone for your application. Required.
131
+ * data - the generic data JSON payload to send to either APNS or GCM.
132
+
133
+ APNS specific:
134
+
135
+ * alert - the text to send as a popup message. Optional.
136
+ * badge - the number to show on the menu icon. Optional.
137
+ * sound - the application sound file to play on the phone. Optional.
138
+ * identifier - 4-byte unique identifier for the message. Optional.
139
+
140
+ ### Example Response (APNS)
141
+
142
+ {
143
+ "response": "ok"
144
+ }
145
+
146
+ ### Example Response (GCM)
147
+
148
+ { "multicast_id": 108,
149
+ "success": 1,
150
+ "failure": 0,
151
+ "canonical_ids": 0,
152
+ "results": [
153
+ { "message_id": "1:08" }
154
+ ]
155
+ }
156
+
157
+ ### Possible errors
158
+
159
+ {
160
+ "error": "missing_identifier",
161
+ "message": "an application_id or gcm_api_key are required for this call"
162
+ }
163
+
164
+ {
165
+ "error": "device_token_required",
166
+ "message": "device_token is required, which is registed with the application on your phone"
167
+ }
168
+
169
+ {
170
+ "error": "application_not_found",
171
+ "message": "no application found for the provided application_id"
172
+ }
173
+
174
+ {
175
+ "error": "invalid_data_json",
176
+ "message": "data could not be converted to json: ERROR_MESSAGE_FROM_JSON_PARSER"
177
+ }
178
+
179
+ {
180
+ "error": "invalid_data_json",
181
+ "message": "data could not be converted to json: ERROR_MESSAGE_FROM_JSON_PARSER"
182
+ }
183
+
184
+ {
185
+ "error": "invalid_gcm_credentials",
186
+ "message": "the provided credentials were rejected by google servers"
187
+ }
@@ -0,0 +1,10 @@
1
+ require "rake/testtask"
2
+
3
+ desc "Run all tests"
4
+ Rake::TestTask.new do |t|
5
+ t.libs << "test"
6
+ t.test_files = FileList['test/*_test.rb']
7
+ t.verbose = true
8
+ end
9
+
10
+ task :default => :test
@@ -0,0 +1,238 @@
1
+ # `POST /register_application`
2
+
3
+ TODO: Update spec with updated parameter names.
4
+
5
+ TODO: feedback_url not yet implemented.
6
+
7
+ * app_id: Required. A unique identifier for this application.
8
+ * feedback\_url: http://yoursite.com/notification_errors
9
+ * mode: development or production. Only applies to apple.
10
+
11
+ Provide either a P12 certificate or a Google GCM API Key
12
+
13
+ * p12: The P12 certificate as a string
14
+ * gcm\_api\_key: sefsdjfsdlkfj
15
+
16
+ ## Response
17
+
18
+ ```
19
+ {
20
+ "application_id": "a9b20610187a0130d39614109feae54c"
21
+ }
22
+ ```
23
+
24
+ ## Errors
25
+
26
+ ### /application_feedback
27
+
28
+ ```
29
+ {
30
+ "error": "missing_identifier",
31
+ "message": "an application\_id or gcm\_api\_key are required for this call"
32
+ }
33
+ ```
34
+
35
+ ```
36
+ {
37
+ "error": "application\_not\_found",
38
+ "message": "no application found for the provided application_id"
39
+ }
40
+ ```
41
+
42
+ ```
43
+ {
44
+ "error": "expired_certificate",
45
+ "message": "The certificate provided has expired. Please generate a new certificate."
46
+ }
47
+ ```
48
+
49
+
50
+ # `/send_notification?type=apns`
51
+
52
+ TODO: Explain why the "device_token" parameter implemented but not the plural version.
53
+
54
+ * device_tokens - array or string. Apple does not support multiple, so just loop for that case.x
55
+ * alert - "text message to send or see apple docs for other options"
56
+ * badge - example: 5
57
+ * sound - "soundfile.aiff"
58
+ * identifier - unique key for message. enforce as a 32 bit integer if grocer does not
59
+ * data - JSON to dump in the root with the aps payload.
60
+
61
+ ## Errors
62
+
63
+ ```
64
+ {
65
+ "error": "invalid_input",
66
+ "message": "badge must be an integer"
67
+ }
68
+ ```
69
+
70
+ ```
71
+ {
72
+ "error": "missing_application",
73
+ "message": "The application could not be found. Register the application first using POST /register\_application"
74
+ }
75
+ ```
76
+
77
+ ### APNS Error Codes
78
+
79
+ TODO: Error responses are not sent back from the send_notification endpoint. Why not?
80
+
81
+ * 1: processing error
82
+ * 2: missing device token
83
+ * 3: missing topic
84
+ * 4: missing payload
85
+ * 5: invalid token size
86
+ * 6: invalid topic size
87
+ * 7: invalid payload size
88
+ * 8: invalid token
89
+ * 255: unknown
90
+
91
+ ```
92
+ {
93
+ "error": "missing_topic",
94
+ "code": 3,
95
+ "device_token": "b85a40425e91a68512259b40f480a2a645efd185106dc66a3e73f6c89e95c963",
96
+ "identifier": "asdjfhd"
97
+ }
98
+ ```
99
+
100
+ ## APNS Payload Examples (Internal use only)
101
+
102
+ ### Send text:
103
+ ```
104
+ {
105
+ "aps": {
106
+ "alert": "hello"
107
+ }
108
+ }
109
+ ```
110
+
111
+ ### Set number on badge:
112
+ ```
113
+ {
114
+ "aps": {
115
+ "badge": 9
116
+ }
117
+ }
118
+ ```
119
+
120
+ ### Sound (plays sound)
121
+ ```
122
+ {
123
+ "aps": {
124
+ "sound": "ping.aiff"
125
+ }
126
+ }
127
+ ```
128
+
129
+ All three of these parameters can be combined. You can also send a push notification with custom data and leave out the standard "aps" keys.
130
+
131
+ Other keys can be sent outside of the aps root. Example:
132
+
133
+ ```
134
+ {
135
+ "aps": {
136
+ "alert": "hello"
137
+ },
138
+ "hello": "kitty"
139
+ }
140
+ ```
141
+
142
+
143
+ # `/send_notification?type=gcm`
144
+
145
+ * app_id OR gcm\_api\_key
146
+ * device_tokens - json array or string (one device)
147
+ * data - the notification payload
148
+ * collapse\_key - if you send multiple pushes with the same collapse\_key, only the last will be delivered when the phone comes back online
149
+ * delay\_while\_idle - this will send the message when the device comes back from idle. Default: false
150
+ * time\_to\_live - number of seconds the message should be kept if the device is offline. Default: 4 weeks
151
+ * dry\_run - default: false
152
+
153
+ ## Input/Format Errors
154
+
155
+ ```
156
+ {
157
+ "error": "missing_input",
158
+ "message": "data is required"
159
+ }
160
+ ```
161
+
162
+ ```
163
+ {
164
+ "error": "missing_input",
165
+ "message": "device_tokens is required"
166
+ }
167
+ ```
168
+
169
+ ```
170
+ {
171
+ "error": "missing_input",
172
+ "message": "app_id OR gcm_api_key is required"
173
+ }
174
+ ```
175
+
176
+ These errors will come from the GCM service
177
+
178
+ `{"error": "message_too_big"}`
179
+
180
+ `{"error": "invalid_data_key"}`
181
+
182
+ `{"error": "invalid_ttl"}`
183
+
184
+ ## Error format for multiple responses
185
+
186
+ If the input format is valid, the service will attempt to send the notification. The results of sending each notification will be returned in "results".
187
+
188
+ ```
189
+ {
190
+ "results": [
191
+ {"device_token": "fsdsfegr", "error": "invalid_registration"},
192
+ {"device_token": "dsdfdgfg", "success": true}
193
+ ]
194
+ }
195
+ ```
196
+
197
+ ## Other possbile errors from the GCM service
198
+
199
+ * 401: "Invalid API Key"
200
+ * MissingRegistration
201
+ * InvalidRegistration
202
+ * MismatchSenderId
203
+ * NotRegistered
204
+ * MessageTooBig
205
+ * InvalidDataKey
206
+ * InvalidTtl
207
+ * InternalServerError
208
+
209
+ {"type": "MissingRegistration", device_token: "sdfsdf"} <- maybe convert to undercase?
210
+
211
+ ## GCM Documentation
212
+
213
+ * http://developer.android.com/guide/google/gcm/index.html
214
+ * http://developer.android.com/guide/google/gcm/adv.html
215
+
216
+
217
+ # `apns\_feedback`
218
+
219
+ TODO: Update this explanation with a reason why this was implemented as a GET API
220
+ method instead of as described below.
221
+
222
+ The Pushlet service will periodically check the APNS feedback service for errors. If any
223
+ errors are reported by Apple, Pushlet will deliver a POST to the registered `callback_url`
224
+ for the application.
225
+
226
+ The feedback payload will contain the device token and the timestamp of the error.
227
+
228
+
229
+ ```
230
+ {
231
+ "app_id": "14002",
232
+ "type": "feedback",
233
+ "device_token": "b85a40425e91a68512259b40f480a2a645efd185106dc66a3e73f6c89e95c963",
234
+ "timestamp": 1352502272
235
+ }
236
+ ```
237
+
238
+
@@ -0,0 +1,20 @@
1
+ libdir = File.dirname(__FILE__)
2
+ $LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir)
3
+ require 'json'
4
+ require 'sinatra'
5
+ require 'escape'
6
+ require 'openssl'
7
+ require 'base64'
8
+ require 'tempfile'
9
+ require 'escape'
10
+ require 'uuid'
11
+ require 'running'
12
+ require 'sinatra/jsonapi'
13
+ require 'grocer'
14
+ require 'httpclient'
15
+
16
+ require 'pushlet/version'
17
+ require 'pushlet/apns_gateway'
18
+ require 'pushlet/gateway_pool'
19
+ require 'pushlet/auth_middleware'
20
+ require 'pushlet/api'
@@ -0,0 +1,122 @@
1
+ module Pushlet
2
+ class API < Sinatra::Base
3
+ register Sinatra::JSONAPI
4
+ set :show_exceptions, false
5
+
6
+ class << self
7
+ def gateway_pool
8
+ @gateway_pool ||= GatewayPool.new
9
+ end
10
+
11
+ def uuid
12
+ @uuid ||= UUID.new
13
+ end
14
+
15
+ def http_client
16
+ @http_client ||= HTTPClient.new
17
+ end
18
+
19
+ def http_basic_auth(user, pass)
20
+ use AuthMiddleware, user, pass
21
+ end
22
+ end
23
+
24
+ get '/application_feedback' do
25
+ if p[:application_id].nil?
26
+ api_error :missing_application_id, 'an application_id is required for this call'
27
+ end
28
+
29
+ app = self.class.gateway_pool.get p[:application_id]
30
+ api_error :application_not_found, 'no application found for the provided application_id' if app.nil?
31
+
32
+ api_response response: app.feedback.collect {|fda| {device_token: fda.device_token, timestamp: fda.timestamp.utc.iso8601}}
33
+ end
34
+
35
+ post '/register_application' do
36
+ id = p[:application_id] || self.class.uuid.generate(:compact)
37
+
38
+ if p[:apns_p12].nil? && p[:apns_pem].nil?
39
+ api_error :missing_apns_certificate, 'an apns_p12 or apns_pem certificate is required for this call'
40
+ end
41
+
42
+ api_error :apns_mode_required, 'specify apns_mode=production or apns_mode=development' unless ['production','development'].include? p[:apns_mode]
43
+
44
+ apns_p12 = p[:apns_p12].is_a?(Hash) ? p[:apns_p12][:tempfile].read : p[:apns_p12]
45
+ apns_pem = p[:apns_pem].is_a?(Hash) ? p[:apns_pem][:tempfile].read : p[:apns_pem]
46
+
47
+ begin
48
+ apns_gateway = APNSGateway.new({
49
+ mode: p[:apns_mode],
50
+ p12: apns_p12,
51
+ pem: apns_pem,
52
+ p12_pass: p[:apns_p12_pass]
53
+ })
54
+ rescue OpenSSL::PKCS12::PKCS12Error
55
+ api_error :invalid_apns_p12, 'could not process apns_p12 file, check that it is in p12 format and is valid'
56
+ end
57
+
58
+ self.class.gateway_pool.add id, apns_gateway
59
+
60
+ api_response application_id: id
61
+ end
62
+
63
+ post '/send_notification' do
64
+ if p[:application_id].nil? && p[:gcm_api_key].nil?
65
+ api_error :missing_identifier, 'an application_id or gcm_api_key are required for this call'
66
+ end
67
+
68
+ if p[:device_token].nil? || p[:device_token].empty?
69
+ api_error :device_token_required, 'device_token is required, which is registed with the application on your phone'
70
+ end
71
+
72
+ if p[:application_id]
73
+ app = self.class.gateway_pool.get p[:application_id]
74
+ api_error :application_not_found, 'no application found for the provided application_id' if app.nil?
75
+
76
+ api_error :missing_payload, 'at least one of alert, badge, sound or data is required' if p[:alert].nil? && p[:badge].nil? && p[:sound].nil? && p[:data].nil?
77
+
78
+ app.push({
79
+ device_token: p[:device_token],
80
+ alert: p[:alert],
81
+ badge: p[:badge],
82
+ sound: p[:sound],
83
+ identifier: p[:identifier],
84
+ custom: p[:data]
85
+ })
86
+
87
+ api_response response: 'ok'
88
+ else
89
+ api_error :gcm_api_key_required, 'must provide gcm_api_key' if p[:gcm_api_key].nil?
90
+
91
+ payload = {registration_ids: [p[:device_token]]}
92
+
93
+ [:collapse_key, :data, :delay_while_idle, :time_to_live, :restricted_package_name, :dry_run].each do |key|
94
+ if key == :data
95
+ begin
96
+ payload[key] = JSON.parse params[:data] if params[:data]
97
+ rescue JSON::ParserError => e
98
+ api_error :invalid_data_json, "data could not be converted to json: #{e.message}"
99
+ end
100
+ else
101
+ payload[key] = p[key] unless p[key].nil?
102
+ end
103
+ end
104
+
105
+ headers = {
106
+ 'Content-Type' => 'application/json',
107
+ 'Authorization' => "key=#{p[:gcm_api_key]}"
108
+ }
109
+
110
+ resp = self.class.http_client.post 'https://android.googleapis.com/gcm/send', payload.to_json, headers
111
+
112
+ if resp.status == 401
113
+ api_error :invalid_gcm_credentials, 'the provided credentials were rejected by google servers'
114
+ else
115
+ api_response response: JSON.parse(resp.body)
116
+ end
117
+ end
118
+ end
119
+
120
+ def p; params end
121
+ end
122
+ end
@@ -0,0 +1,101 @@
1
+ require 'digest'
2
+ require 'time'
3
+
4
+ module Pushlet
5
+ class APNSGateway
6
+ def initialize(o={})
7
+ @mode = o[:mode]
8
+ @pass = o[:p12_pass]
9
+ @pem = o[:pem] || self.class.p12_to_pem_text(o[:p12], o[:p12_pass])
10
+ create_pusher
11
+ end
12
+
13
+ def feedback
14
+ feedback = Grocer.feedback(
15
+ certificate: StringIO.new(@pem),
16
+ passphrase: @pass,
17
+ gateway: (@mode == "development" ? gateway_sandbox : gateway_production),
18
+ port: feedback_port,
19
+ retries: retries
20
+ )
21
+
22
+ failed_delivery_attempts = []
23
+
24
+ feedback.each do |fda|
25
+ failed_delivery_attempts << fda
26
+ end
27
+
28
+ failed_delivery_attempts
29
+ end
30
+
31
+ def create_pusher
32
+ @pusher = Grocer.pusher(
33
+ certificate: StringIO.new(@pem),
34
+ passphrase: @pass,
35
+ gateway: (@mode == "development" ? gateway_sandbox : gateway_production),
36
+ port: port,
37
+ retries: retries
38
+ )
39
+ end
40
+
41
+ def gateway_sandbox; 'gateway.sandbox.push.apple.com' end
42
+ def gateway_production; 'gateway.push.apple.com' end
43
+ def port; 2195 end
44
+ def feedback_port; 2196 end
45
+ def retries; 3 end
46
+
47
+ def push(args={})
48
+ if args[:identifier]
49
+ identifier = args[:identifier]
50
+ else
51
+ # Seriously Apple, a 4 byte identifier? Really guys?
52
+ identifier = SecureRandom.random_bytes(4)
53
+ end
54
+
55
+ payload = {}
56
+
57
+ [:device_token, :alert, :badge, :sound, :expiry, :identifier].each do |a|
58
+ if a == :expiry
59
+ payload[a] = args[a] || Time.now + 60*10
60
+ else
61
+ payload[a] = args[a] if args[a]
62
+ end
63
+ end
64
+
65
+ notification = Grocer::Notification.new payload
66
+
67
+ @pusher.push(notification)
68
+ end
69
+
70
+ def self.p12_to_pem_text(p12, p12_pass='')
71
+ p12_pass = '' if p12_pass.nil?
72
+
73
+ if Running.jruby?
74
+ tf_p12 = Tempfile.new 'tf_p12'
75
+ tf_p12.write p12
76
+ tf_p12.rewind
77
+ tf_p12.close
78
+
79
+ tf_pem = Tempfile.new 'tf_pem'
80
+ tf_pem.close
81
+
82
+ cmd = Escape.shell_command(['openssl', 'pkcs12', '-in', tf_p12.path, '-out', tf_pem.path,
83
+ '-nodes', '-clcerts', '-password', 'pass:'+p12_pass]).to_s + ' &> /dev/null'
84
+
85
+ begin
86
+ result = system cmd
87
+ raise CommandFailError if result.nil? || result == false
88
+ pem = File.read tf_pem.path
89
+ ensure
90
+ tf_pem.unlink
91
+ tf_p12.unlink
92
+ end
93
+
94
+ pem
95
+ else
96
+ pkcs12 = OpenSSL::PKCS12.new p12, p12_pass
97
+ pkcs12.certificate.to_s + pkcs12.key.to_s
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,29 @@
1
+ module Pushlet
2
+ class AuthMiddleware
3
+ def initialize(app, username, password)
4
+ @app = app
5
+ @username = username
6
+ @password = password
7
+ end
8
+
9
+ def call(env)
10
+ return error_json if env['HTTP_AUTHORIZATION'].nil? || env['HTTP_AUTHORIZATION'] == ''
11
+
12
+ begin
13
+ username, password = Base64.strict_decode64(env['HTTP_AUTHORIZATION'].split('Basic ').last).split(':')
14
+ rescue
15
+ return error_json
16
+ end
17
+
18
+ return error_json if username != @username || password != @password
19
+
20
+ @app.call env
21
+ end
22
+
23
+ def error_json(type='access denied', message='invalid credentials')
24
+ payload = {error: {type: type, message: message}}.to_json
25
+
26
+ [200, {'Content-Type' => 'application/json', 'Content-Length' => payload.length.to_s}, [payload]]
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,30 @@
1
+ module Pushlet
2
+ class GatewayPool
3
+ def initialize
4
+ @mutex = Mutex.new
5
+ @pool = {}
6
+ end
7
+
8
+ def get(id)
9
+ @pool[id.to_sym]
10
+ end
11
+
12
+ def add(id, gateway)
13
+ lock {
14
+ @pool[id.to_sym] = gateway
15
+ }
16
+ end
17
+
18
+ def remove(id)
19
+ lock {
20
+ @pool.delete id.to_sym
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def lock(&block)
27
+ @mutex.synchronize { yield }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,8 @@
1
+ module Pushlet
2
+ # The current version of the Pushlet ruby gem.
3
+ #
4
+ # @return [String]
5
+ def self.version
6
+ '0.2.0'
7
+ end
8
+ end
@@ -0,0 +1,33 @@
1
+ require './lib/pushlet/version.rb'
2
+ Gem::Specification.new do |s|
3
+ s.name = 'pushlet'
4
+ s.version = Pushlet.version
5
+ s.authors = ['Kyle Drake', 'Aaron Parecki']
6
+ s.email = ['kyledrake@gmail.com', 'aaron@parecki.com']
7
+ s.homepage = 'http://github.com/geoloqi/pushlet'
8
+ s.summary = 'Powerful, simple, stateless, multi-threaded push notification service API for APNS and Google GCM'
9
+ s.description = 'Powerful, simple, stateless, multi-threaded push notification service API for APNS and Google GCM!'
10
+
11
+ s.files = `git ls-files`.split("\n")
12
+ s.require_paths = %w[lib]
13
+ s.rubyforge_project = s.name
14
+ s.required_rubygems_version = '>= 1.3.4'
15
+
16
+ s.add_dependency 'json'
17
+ s.add_dependency 'httpclient'
18
+ s.add_dependency 'sinatra'
19
+ s.add_dependency 'sinatra-jsonapi'
20
+ s.add_dependency 'escape'
21
+ s.add_dependency 'running'
22
+ s.add_dependency 'httpclient'
23
+ s.add_dependency 'uuid'
24
+ s.add_dependency 'grocer'
25
+ s.add_dependency 'puma'
26
+
27
+ s.add_development_dependency 'rake'
28
+ s.add_development_dependency 'minitest'
29
+ s.add_development_dependency 'rack-test'
30
+ s.add_development_dependency 'webmock'
31
+ s.add_development_dependency 'yard'
32
+ s.add_development_dependency 'mocha'
33
+ end
@@ -0,0 +1,21 @@
1
+ ENV['RACK_ENV'] = 'test'
2
+ require 'rubygems'
3
+ require 'minitest/autorun'
4
+ require 'mocha/setup'
5
+ require 'rack/test'
6
+ require 'webmock'
7
+ require './lib/pushlet.rb'
8
+
9
+ include WebMock::API
10
+ include Rack::Test::Methods
11
+
12
+ # For rack-test
13
+ def app
14
+ @app ||= Sinatra.new Pushlet::API do
15
+ enable :raise_errors
16
+ end
17
+ end
18
+
19
+ def resp
20
+ JSON.parse last_response.body, symbolize_names: true
21
+ end
@@ -0,0 +1,14 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIICKzCCAdWgAwIBAgIJAKDDs5zNJQVeMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
3
+ BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
4
+ aWRnaXRzIFB0eSBMdGQwHhcNMTIwMzI5MTg1MDEwWhcNMTIwNDI4MTg1MDEwWjBF
5
+ MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50
6
+ ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANlO
7
+ UQLaP+NWbniFq2I0yW7AOyOjrKuEBj9i4cVOR1EWN2wnf64gmRucz+DKmxfYr5kq
8
+ +XT6GIpEdw0wrBGsKlUCAwEAAaOBpzCBpDAdBgNVHQ4EFgQUVkHor75NOFIKInJN
9
+ VUpmqRbWfsgwdQYDVR0jBG4wbIAUVkHor75NOFIKInJNVUpmqRbWfsihSaRHMEUx
10
+ CzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRl
11
+ cm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCgw7OczSUFXjAMBgNVHRMEBTADAQH/MA0G
12
+ CSqGSIb3DQEBBQUAA0EAzPoxFj8j1uUIEGGsViUXkAH9/uoZuCBy9PtHEJcjSkya
13
+ TlKHfzEaEsA8pfmHax3gMgYcIbbWuYVR/HTUEnog9Q==
14
+ -----END CERTIFICATE-----
@@ -0,0 +1,9 @@
1
+ -----BEGIN RSA PRIVATE KEY-----
2
+ MIIBOwIBAAJBANlOUQLaP+NWbniFq2I0yW7AOyOjrKuEBj9i4cVOR1EWN2wnf64g
3
+ mRucz+DKmxfYr5kq+XT6GIpEdw0wrBGsKlUCAwEAAQJBAISneW67Wrrl/WQXfWri
4
+ /IBeWvvDo9nEK6gwLdCrm3+UZ18Tr9scjdvWsRbHy6fiIYIIZY0KA0mIx1TlPxbD
5
+ ANUCIQDuc5KeAJZ5bVDWK/Qca6/kAtx6jKH0Q8A4M4vhEynHwwIhAOlMXJqUyt4p
6
+ As+jG3jRz820QQW5CkjQ4rNCvDgnEjwHAiArzn+5F1KNrE+ViS2nqwD9Wqk2um9m
7
+ eKvvp0ijaOncEQIgG7dwwQSwXVht9xEfsGjs0Tl7CB0FtcTrSfTBu8IYjn0CIQCh
8
+ tJEYZA1szHnTdHA2V8FvQ9ZDC3KW6pBvKDQJkqPIaw==
9
+ -----END RSA PRIVATE KEY-----
Binary file
@@ -0,0 +1,23 @@
1
+ -----BEGIN RSA PRIVATE KEY-----
2
+ MIIBOwIBAAJBANlOUQLaP+NWbniFq2I0yW7AOyOjrKuEBj9i4cVOR1EWN2wnf64g
3
+ mRucz+DKmxfYr5kq+XT6GIpEdw0wrBGsKlUCAwEAAQJBAISneW67Wrrl/WQXfWri
4
+ /IBeWvvDo9nEK6gwLdCrm3+UZ18Tr9scjdvWsRbHy6fiIYIIZY0KA0mIx1TlPxbD
5
+ ANUCIQDuc5KeAJZ5bVDWK/Qca6/kAtx6jKH0Q8A4M4vhEynHwwIhAOlMXJqUyt4p
6
+ As+jG3jRz820QQW5CkjQ4rNCvDgnEjwHAiArzn+5F1KNrE+ViS2nqwD9Wqk2um9m
7
+ eKvvp0ijaOncEQIgG7dwwQSwXVht9xEfsGjs0Tl7CB0FtcTrSfTBu8IYjn0CIQCh
8
+ tJEYZA1szHnTdHA2V8FvQ9ZDC3KW6pBvKDQJkqPIaw==
9
+ -----END RSA PRIVATE KEY-----
10
+ -----BEGIN CERTIFICATE-----
11
+ MIICKzCCAdWgAwIBAgIJAKDDs5zNJQVeMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
12
+ BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
13
+ aWRnaXRzIFB0eSBMdGQwHhcNMTIwMzI5MTg1MDEwWhcNMTIwNDI4MTg1MDEwWjBF
14
+ MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50
15
+ ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANlO
16
+ UQLaP+NWbniFq2I0yW7AOyOjrKuEBj9i4cVOR1EWN2wnf64gmRucz+DKmxfYr5kq
17
+ +XT6GIpEdw0wrBGsKlUCAwEAAaOBpzCBpDAdBgNVHQ4EFgQUVkHor75NOFIKInJN
18
+ VUpmqRbWfsgwdQYDVR0jBG4wbIAUVkHor75NOFIKInJNVUpmqRbWfsihSaRHMEUx
19
+ CzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRl
20
+ cm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCgw7OczSUFXjAMBgNVHRMEBTADAQH/MA0G
21
+ CSqGSIb3DQEBBQUAA0EAzPoxFj8j1uUIEGGsViUXkAH9/uoZuCBy9PtHEJcjSkya
22
+ TlKHfzEaEsA8pfmHax3gMgYcIbbWuYVR/HTUEnog9Q==
23
+ -----END CERTIFICATE-----
@@ -0,0 +1,32 @@
1
+ require './test/env.rb'
2
+
3
+ describe 'send gcm push' do
4
+ it 'fails without key' do
5
+ post '/send_notification'
6
+ resp[:error][:type].must_equal 'missing_identifier'
7
+ end
8
+
9
+ it 'fails without one or more device tokens' do
10
+ post '/send_notification', gcm_api_key: 'ABCD', data: {text: 'hello'}
11
+ resp[:error][:type].must_equal 'device_token_required'
12
+ end
13
+
14
+ it 'should fail gracefully for invalid key' do
15
+
16
+ end
17
+
18
+ it 'works' do
19
+
20
+ stub_request(:post, "https://android.googleapis.com/gcm/send").
21
+ with(:body => "{\"registration_ids\":[\"1234\"],\"data\":{\"text\":\"hello\"},\"collapse_key\":null,\"delay_while_idle\":null,\"time_to_live\":null,\"dry_run\":null}",
22
+ :headers => {'Authorization'=>'key=ABCD', 'Content-Type'=>'application/json'}).
23
+ to_return(:status => 200, :body => %{{"multicast_id":6782339717028231855,"success":0,"failure":1,"canonical_ids":0,"results":[{"error":"InvalidRegistration"}]}})
24
+
25
+ post '/send_notification', gcm_api_key: 'ABCD', data: {text: 'hello'}, device_tokens: ['1234']
26
+
27
+
28
+
29
+ # binding.pry
30
+
31
+ end
32
+ end
@@ -0,0 +1,51 @@
1
+ require './test/env.rb'
2
+
3
+ describe 'registering apns applications' do
4
+ it 'should fail without input' do
5
+ post '/register_application'
6
+ resp[:application_id].must_be_nil
7
+ resp[:error][:type].must_equal 'missing_apns_certificate'
8
+ end
9
+
10
+ it 'fails with bad cert' do
11
+ post '/register_application', apns_p12: File.read('./test/files/example.pem'), apns_mode: 'production'
12
+ resp[:application_id].must_be_nil
13
+ resp[:error][:type].must_equal 'invalid_apns_p12'
14
+ end
15
+
16
+ it 'works' do
17
+ post '/register_application', apns_p12: File.read('./test/files/example.p12'), apns_mode: 'production'
18
+ resp[:application_id].wont_be_nil
19
+ end
20
+
21
+ it 'works with provided id' do
22
+ post '/register_application', apns_p12: File.read('./test/files/example.p12'), application_id: 'specialapp', apns_mode: 'production'
23
+ resp[:application_id].must_equal 'specialapp'
24
+ end
25
+
26
+ it 'fails for passworded p12 with bad pass' do
27
+ post '/register_application', apns_p12: File.read('./test/files/example_with_pass.p12'), apns_p12_pass: 'fail', apns_mode: 'production'
28
+ resp[:application_id].must_be_nil
29
+ resp[:error][:type].must_equal 'invalid_apns_p12'
30
+ end
31
+
32
+ it 'works with pass' do
33
+ post '/register_application', apns_p12: File.read('./test/files/example_with_pass.p12'), apns_p12_pass: 'test', apns_mode: 'production'
34
+ resp[:application_id].wont_be_nil
35
+ end
36
+
37
+ it 'works with pem instead of p12' do
38
+ post '/register_application', apns_pem: File.read('./test/files/example.pem'), apns_mode: 'production'
39
+ resp[:application_id].wont_be_nil
40
+ end
41
+
42
+ it 'fails if no apns_mode provided' do
43
+ post '/register_application', apns_pem: File.read('./test/files/example.pem')
44
+ resp[:error][:type].must_equal 'apns_mode_required'
45
+ end
46
+
47
+ it 'fails on invalid apns_mode' do
48
+ post '/register_application', apns_pem: File.read('./test/files/example.pem'), apns_mode: 'whatever'
49
+ resp[:error][:type].must_equal 'apns_mode_required'
50
+ end
51
+ end
metadata ADDED
@@ -0,0 +1,326 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pushlet
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Kyle Drake
9
+ - Aaron Parecki
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2012-12-04 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: json
17
+ requirement: !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ! '>='
21
+ - !ruby/object:Gem::Version
22
+ version: '0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ none: false
27
+ requirements:
28
+ - - ! '>='
29
+ - !ruby/object:Gem::Version
30
+ version: '0'
31
+ - !ruby/object:Gem::Dependency
32
+ name: httpclient
33
+ requirement: !ruby/object:Gem::Requirement
34
+ none: false
35
+ requirements:
36
+ - - ! '>='
37
+ - !ruby/object:Gem::Version
38
+ version: '0'
39
+ type: :runtime
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ none: false
43
+ requirements:
44
+ - - ! '>='
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: sinatra
49
+ requirement: !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :runtime
56
+ prerelease: false
57
+ version_requirements: !ruby/object:Gem::Requirement
58
+ none: false
59
+ requirements:
60
+ - - ! '>='
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ - !ruby/object:Gem::Dependency
64
+ name: sinatra-jsonapi
65
+ requirement: !ruby/object:Gem::Requirement
66
+ none: false
67
+ requirements:
68
+ - - ! '>='
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ type: :runtime
72
+ prerelease: false
73
+ version_requirements: !ruby/object:Gem::Requirement
74
+ none: false
75
+ requirements:
76
+ - - ! '>='
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ - !ruby/object:Gem::Dependency
80
+ name: escape
81
+ requirement: !ruby/object:Gem::Requirement
82
+ none: false
83
+ requirements:
84
+ - - ! '>='
85
+ - !ruby/object:Gem::Version
86
+ version: '0'
87
+ type: :runtime
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ none: false
91
+ requirements:
92
+ - - ! '>='
93
+ - !ruby/object:Gem::Version
94
+ version: '0'
95
+ - !ruby/object:Gem::Dependency
96
+ name: running
97
+ requirement: !ruby/object:Gem::Requirement
98
+ none: false
99
+ requirements:
100
+ - - ! '>='
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ none: false
107
+ requirements:
108
+ - - ! '>='
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: httpclient
113
+ requirement: !ruby/object:Gem::Requirement
114
+ none: false
115
+ requirements:
116
+ - - ! '>='
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ type: :runtime
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ none: false
123
+ requirements:
124
+ - - ! '>='
125
+ - !ruby/object:Gem::Version
126
+ version: '0'
127
+ - !ruby/object:Gem::Dependency
128
+ name: uuid
129
+ requirement: !ruby/object:Gem::Requirement
130
+ none: false
131
+ requirements:
132
+ - - ! '>='
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ type: :runtime
136
+ prerelease: false
137
+ version_requirements: !ruby/object:Gem::Requirement
138
+ none: false
139
+ requirements:
140
+ - - ! '>='
141
+ - !ruby/object:Gem::Version
142
+ version: '0'
143
+ - !ruby/object:Gem::Dependency
144
+ name: grocer
145
+ requirement: !ruby/object:Gem::Requirement
146
+ none: false
147
+ requirements:
148
+ - - ! '>='
149
+ - !ruby/object:Gem::Version
150
+ version: '0'
151
+ type: :runtime
152
+ prerelease: false
153
+ version_requirements: !ruby/object:Gem::Requirement
154
+ none: false
155
+ requirements:
156
+ - - ! '>='
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ - !ruby/object:Gem::Dependency
160
+ name: puma
161
+ requirement: !ruby/object:Gem::Requirement
162
+ none: false
163
+ requirements:
164
+ - - ! '>='
165
+ - !ruby/object:Gem::Version
166
+ version: '0'
167
+ type: :runtime
168
+ prerelease: false
169
+ version_requirements: !ruby/object:Gem::Requirement
170
+ none: false
171
+ requirements:
172
+ - - ! '>='
173
+ - !ruby/object:Gem::Version
174
+ version: '0'
175
+ - !ruby/object:Gem::Dependency
176
+ name: rake
177
+ requirement: !ruby/object:Gem::Requirement
178
+ none: false
179
+ requirements:
180
+ - - ! '>='
181
+ - !ruby/object:Gem::Version
182
+ version: '0'
183
+ type: :development
184
+ prerelease: false
185
+ version_requirements: !ruby/object:Gem::Requirement
186
+ none: false
187
+ requirements:
188
+ - - ! '>='
189
+ - !ruby/object:Gem::Version
190
+ version: '0'
191
+ - !ruby/object:Gem::Dependency
192
+ name: minitest
193
+ requirement: !ruby/object:Gem::Requirement
194
+ none: false
195
+ requirements:
196
+ - - ! '>='
197
+ - !ruby/object:Gem::Version
198
+ version: '0'
199
+ type: :development
200
+ prerelease: false
201
+ version_requirements: !ruby/object:Gem::Requirement
202
+ none: false
203
+ requirements:
204
+ - - ! '>='
205
+ - !ruby/object:Gem::Version
206
+ version: '0'
207
+ - !ruby/object:Gem::Dependency
208
+ name: rack-test
209
+ requirement: !ruby/object:Gem::Requirement
210
+ none: false
211
+ requirements:
212
+ - - ! '>='
213
+ - !ruby/object:Gem::Version
214
+ version: '0'
215
+ type: :development
216
+ prerelease: false
217
+ version_requirements: !ruby/object:Gem::Requirement
218
+ none: false
219
+ requirements:
220
+ - - ! '>='
221
+ - !ruby/object:Gem::Version
222
+ version: '0'
223
+ - !ruby/object:Gem::Dependency
224
+ name: webmock
225
+ requirement: !ruby/object:Gem::Requirement
226
+ none: false
227
+ requirements:
228
+ - - ! '>='
229
+ - !ruby/object:Gem::Version
230
+ version: '0'
231
+ type: :development
232
+ prerelease: false
233
+ version_requirements: !ruby/object:Gem::Requirement
234
+ none: false
235
+ requirements:
236
+ - - ! '>='
237
+ - !ruby/object:Gem::Version
238
+ version: '0'
239
+ - !ruby/object:Gem::Dependency
240
+ name: yard
241
+ requirement: !ruby/object:Gem::Requirement
242
+ none: false
243
+ requirements:
244
+ - - ! '>='
245
+ - !ruby/object:Gem::Version
246
+ version: '0'
247
+ type: :development
248
+ prerelease: false
249
+ version_requirements: !ruby/object:Gem::Requirement
250
+ none: false
251
+ requirements:
252
+ - - ! '>='
253
+ - !ruby/object:Gem::Version
254
+ version: '0'
255
+ - !ruby/object:Gem::Dependency
256
+ name: mocha
257
+ requirement: !ruby/object:Gem::Requirement
258
+ none: false
259
+ requirements:
260
+ - - ! '>='
261
+ - !ruby/object:Gem::Version
262
+ version: '0'
263
+ type: :development
264
+ prerelease: false
265
+ version_requirements: !ruby/object:Gem::Requirement
266
+ none: false
267
+ requirements:
268
+ - - ! '>='
269
+ - !ruby/object:Gem::Version
270
+ version: '0'
271
+ description: Powerful, simple, stateless, multi-threaded push notification service
272
+ API for APNS and Google GCM!
273
+ email:
274
+ - kyledrake@gmail.com
275
+ - aaron@parecki.com
276
+ executables: []
277
+ extensions: []
278
+ extra_rdoc_files: []
279
+ files:
280
+ - .gitignore
281
+ - Gemfile
282
+ - README.md
283
+ - Rakefile
284
+ - SPEC-OLD.md
285
+ - lib/pushlet.rb
286
+ - lib/pushlet/api.rb
287
+ - lib/pushlet/apns_gateway.rb
288
+ - lib/pushlet/auth_middleware.rb
289
+ - lib/pushlet/gateway_pool.rb
290
+ - lib/pushlet/version.rb
291
+ - pushlet.gemspec
292
+ - test/env.rb
293
+ - test/files/example.cer
294
+ - test/files/example.key
295
+ - test/files/example.p12
296
+ - test/files/example.pem
297
+ - test/files/example_with_pass.p12
298
+ - test/gcm_push_test.rb
299
+ - test/register_application_test.rb
300
+ homepage: http://github.com/geoloqi/pushlet
301
+ licenses: []
302
+ post_install_message:
303
+ rdoc_options: []
304
+ require_paths:
305
+ - lib
306
+ required_ruby_version: !ruby/object:Gem::Requirement
307
+ none: false
308
+ requirements:
309
+ - - ! '>='
310
+ - !ruby/object:Gem::Version
311
+ version: '0'
312
+ required_rubygems_version: !ruby/object:Gem::Requirement
313
+ none: false
314
+ requirements:
315
+ - - ! '>='
316
+ - !ruby/object:Gem::Version
317
+ version: 1.3.4
318
+ requirements: []
319
+ rubyforge_project: pushlet
320
+ rubygems_version: 1.8.24
321
+ signing_key:
322
+ specification_version: 3
323
+ summary: Powerful, simple, stateless, multi-threaded push notification service API
324
+ for APNS and Google GCM
325
+ test_files: []
326
+ has_rdoc: