pushlet 0.2.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,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: