rpush_extended 3.2.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CHANGELOG.md +365 -0
- data/LICENSE +7 -0
- data/README.md +393 -0
- data/bin/rpush +4 -0
- data/lib/generators/rpush_config_generator.rb +7 -0
- data/lib/generators/rpush_migration_generator.rb +66 -0
- data/lib/generators/templates/add_adm.rb +23 -0
- data/lib/generators/templates/add_alert_is_json_to_rapns_notifications.rb +9 -0
- data/lib/generators/templates/add_app_to_rapns.rb +11 -0
- data/lib/generators/templates/add_fail_after_to_rpush_notifications.rb +9 -0
- data/lib/generators/templates/add_gcm.rb +117 -0
- data/lib/generators/templates/add_rpush.rb +402 -0
- data/lib/generators/templates/add_wpns.rb +16 -0
- data/lib/generators/templates/create_rapns_apps.rb +16 -0
- data/lib/generators/templates/create_rapns_feedback.rb +25 -0
- data/lib/generators/templates/create_rapns_notifications.rb +36 -0
- data/lib/generators/templates/rename_rapns_to_rpush.rb +87 -0
- data/lib/generators/templates/rpush.rb +135 -0
- data/lib/generators/templates/rpush_2_0_0_updates.rb +79 -0
- data/lib/generators/templates/rpush_2_1_0_updates.rb +11 -0
- data/lib/generators/templates/rpush_2_6_0_updates.rb +10 -0
- data/lib/generators/templates/rpush_2_7_0_updates.rb +12 -0
- data/lib/generators/templates/rpush_3_0_0_updates.rb +11 -0
- data/lib/generators/templates/rpush_3_0_1_updates.rb +13 -0
- data/lib/generators/templates/rpush_3_1_0_add_pushy.rb +9 -0
- data/lib/generators/templates/rpush_3_1_1_updates.rb +15 -0
- data/lib/generators/templates/rpush_3_2_0_add_apns_p8.rb +15 -0
- data/lib/generators/templates/rpush_3_2_4_updates.rb +9 -0
- data/lib/generators/templates/rpush_3_3_0_updates.rb +9 -0
- data/lib/generators/templates/rpush_3_3_1_updates.rb +11 -0
- data/lib/rpush/apns_feedback.rb +17 -0
- data/lib/rpush/cli.rb +213 -0
- data/lib/rpush/client/active_model/adm/app.rb +23 -0
- data/lib/rpush/client/active_model/adm/data_validator.rb +14 -0
- data/lib/rpush/client/active_model/adm/notification.rb +28 -0
- data/lib/rpush/client/active_model/apns/app.rb +37 -0
- data/lib/rpush/client/active_model/apns/binary_notification_validator.rb +16 -0
- data/lib/rpush/client/active_model/apns/device_token_format_validator.rb +14 -0
- data/lib/rpush/client/active_model/apns/notification.rb +104 -0
- data/lib/rpush/client/active_model/apns2/app.rb +15 -0
- data/lib/rpush/client/active_model/apns2/notification.rb +9 -0
- data/lib/rpush/client/active_model/apnsp8/app.rb +23 -0
- data/lib/rpush/client/active_model/apnsp8/notification.rb +9 -0
- data/lib/rpush/client/active_model/gcm/app.rb +19 -0
- data/lib/rpush/client/active_model/gcm/expiry_collapse_key_mutual_inclusion_validator.rb +14 -0
- data/lib/rpush/client/active_model/gcm/notification.rb +59 -0
- data/lib/rpush/client/active_model/notification.rb +22 -0
- data/lib/rpush/client/active_model/payload_data_size_validator.rb +13 -0
- data/lib/rpush/client/active_model/pushy/app.rb +20 -0
- data/lib/rpush/client/active_model/pushy/notification.rb +31 -0
- data/lib/rpush/client/active_model/pushy/time_to_live_validator.rb +14 -0
- data/lib/rpush/client/active_model/registration_ids_count_validator.rb +13 -0
- data/lib/rpush/client/active_model/wns/app.rb +23 -0
- data/lib/rpush/client/active_model/wns/notification.rb +32 -0
- data/lib/rpush/client/active_model/wpns/app.rb +13 -0
- data/lib/rpush/client/active_model/wpns/notification.rb +28 -0
- data/lib/rpush/client/active_model.rb +34 -0
- data/lib/rpush/client/active_record/adm/app.rb +11 -0
- data/lib/rpush/client/active_record/adm/notification.rb +11 -0
- data/lib/rpush/client/active_record/apns/app.rb +11 -0
- data/lib/rpush/client/active_record/apns/feedback.rb +18 -0
- data/lib/rpush/client/active_record/apns/notification.rb +40 -0
- data/lib/rpush/client/active_record/apns2/app.rb +11 -0
- data/lib/rpush/client/active_record/apns2/notification.rb +10 -0
- data/lib/rpush/client/active_record/apnsp8/app.rb +11 -0
- data/lib/rpush/client/active_record/apnsp8/notification.rb +10 -0
- data/lib/rpush/client/active_record/app.rb +13 -0
- data/lib/rpush/client/active_record/gcm/app.rb +11 -0
- data/lib/rpush/client/active_record/gcm/notification.rb +11 -0
- data/lib/rpush/client/active_record/notification.rb +42 -0
- data/lib/rpush/client/active_record/pushy/app.rb +11 -0
- data/lib/rpush/client/active_record/pushy/notification.rb +11 -0
- data/lib/rpush/client/active_record/wns/app.rb +11 -0
- data/lib/rpush/client/active_record/wns/badge_notification.rb +15 -0
- data/lib/rpush/client/active_record/wns/notification.rb +11 -0
- data/lib/rpush/client/active_record/wns/raw_notification.rb +13 -0
- data/lib/rpush/client/active_record/wpns/app.rb +11 -0
- data/lib/rpush/client/active_record/wpns/notification.rb +11 -0
- data/lib/rpush/client/active_record.rb +33 -0
- data/lib/rpush/client/redis/adm/app.rb +14 -0
- data/lib/rpush/client/redis/adm/notification.rb +11 -0
- data/lib/rpush/client/redis/apns/app.rb +11 -0
- data/lib/rpush/client/redis/apns/feedback.rb +20 -0
- data/lib/rpush/client/redis/apns/notification.rb +11 -0
- data/lib/rpush/client/redis/apns2/app.rb +11 -0
- data/lib/rpush/client/redis/apns2/notification.rb +11 -0
- data/lib/rpush/client/redis/apnsp8/app.rb +11 -0
- data/lib/rpush/client/redis/apnsp8/notification.rb +11 -0
- data/lib/rpush/client/redis/app.rb +29 -0
- data/lib/rpush/client/redis/gcm/app.rb +11 -0
- data/lib/rpush/client/redis/gcm/notification.rb +11 -0
- data/lib/rpush/client/redis/notification.rb +74 -0
- data/lib/rpush/client/redis/pushy/app.rb +16 -0
- data/lib/rpush/client/redis/pushy/notification.rb +18 -0
- data/lib/rpush/client/redis/wns/app.rb +14 -0
- data/lib/rpush/client/redis/wns/badge_notification.rb +15 -0
- data/lib/rpush/client/redis/wns/notification.rb +11 -0
- data/lib/rpush/client/redis/wns/raw_notification.rb +11 -0
- data/lib/rpush/client/redis/wpns/app.rb +11 -0
- data/lib/rpush/client/redis/wpns/notification.rb +11 -0
- data/lib/rpush/client/redis.rb +56 -0
- data/lib/rpush/configuration.rb +115 -0
- data/lib/rpush/daemon/adm/delivery.rb +226 -0
- data/lib/rpush/daemon/adm.rb +9 -0
- data/lib/rpush/daemon/apns/delivery.rb +43 -0
- data/lib/rpush/daemon/apns/feedback_receiver.rb +90 -0
- data/lib/rpush/daemon/apns.rb +17 -0
- data/lib/rpush/daemon/apns2/delivery.rb +127 -0
- data/lib/rpush/daemon/apns2.rb +10 -0
- data/lib/rpush/daemon/apnsp8/delivery.rb +166 -0
- data/lib/rpush/daemon/apnsp8/token.rb +43 -0
- data/lib/rpush/daemon/apnsp8.rb +10 -0
- data/lib/rpush/daemon/app_runner.rb +190 -0
- data/lib/rpush/daemon/batch.rb +138 -0
- data/lib/rpush/daemon/constants.rb +59 -0
- data/lib/rpush/daemon/delivery.rb +46 -0
- data/lib/rpush/daemon/delivery_error.rb +27 -0
- data/lib/rpush/daemon/dispatcher/apns_http2.rb +51 -0
- data/lib/rpush/daemon/dispatcher/apns_tcp.rb +152 -0
- data/lib/rpush/daemon/dispatcher/apnsp8_http2.rb +33 -0
- data/lib/rpush/daemon/dispatcher/http.rb +21 -0
- data/lib/rpush/daemon/dispatcher/tcp.rb +22 -0
- data/lib/rpush/daemon/dispatcher_loop.rb +73 -0
- data/lib/rpush/daemon/errors.rb +18 -0
- data/lib/rpush/daemon/feeder.rb +69 -0
- data/lib/rpush/daemon/gcm/delivery.rb +241 -0
- data/lib/rpush/daemon/gcm.rb +9 -0
- data/lib/rpush/daemon/interruptible_sleep.rb +24 -0
- data/lib/rpush/daemon/loggable.rb +33 -0
- data/lib/rpush/daemon/proc_title.rb +17 -0
- data/lib/rpush/daemon/pushy/delivery.rb +90 -0
- data/lib/rpush/daemon/pushy.rb +9 -0
- data/lib/rpush/daemon/queue_payload.rb +12 -0
- data/lib/rpush/daemon/retry_header_parser.rb +23 -0
- data/lib/rpush/daemon/retryable_error.rb +22 -0
- data/lib/rpush/daemon/ring_buffer.rb +16 -0
- data/lib/rpush/daemon/rpc/client.rb +27 -0
- data/lib/rpush/daemon/rpc/server.rb +82 -0
- data/lib/rpush/daemon/rpc.rb +9 -0
- data/lib/rpush/daemon/service_config_methods.rb +51 -0
- data/lib/rpush/daemon/signal_handler.rb +75 -0
- data/lib/rpush/daemon/store/active_record/reconnectable.rb +80 -0
- data/lib/rpush/daemon/store/active_record.rb +214 -0
- data/lib/rpush/daemon/store/interface.rb +20 -0
- data/lib/rpush/daemon/store/redis.rb +166 -0
- data/lib/rpush/daemon/string_helpers.rb +15 -0
- data/lib/rpush/daemon/synchronizer.rb +62 -0
- data/lib/rpush/daemon/tcp_connection.rb +190 -0
- data/lib/rpush/daemon/wns/badge_request.rb +32 -0
- data/lib/rpush/daemon/wns/delivery.rb +178 -0
- data/lib/rpush/daemon/wns/post_request.rb +33 -0
- data/lib/rpush/daemon/wns/raw_request.rb +22 -0
- data/lib/rpush/daemon/wns/toast_request.rb +54 -0
- data/lib/rpush/daemon/wns.rb +9 -0
- data/lib/rpush/daemon/wpns/delivery.rb +132 -0
- data/lib/rpush/daemon/wpns.rb +9 -0
- data/lib/rpush/daemon.rb +179 -0
- data/lib/rpush/deprecatable.rb +24 -0
- data/lib/rpush/deprecation.rb +26 -0
- data/lib/rpush/embed.rb +41 -0
- data/lib/rpush/logger.rb +92 -0
- data/lib/rpush/multi_json_helper.rb +16 -0
- data/lib/rpush/plugin.rb +44 -0
- data/lib/rpush/push.rb +11 -0
- data/lib/rpush/reflectable.rb +13 -0
- data/lib/rpush/reflection_collection.rb +44 -0
- data/lib/rpush/reflection_public_methods.rb +11 -0
- data/lib/rpush/version.rb +14 -0
- data/lib/rpush.rb +43 -0
- data/lib/tasks/quality.rake +35 -0
- data/lib/tasks/test.rake +69 -0
- data/spec/.rubocop.yml +4 -0
- data/spec/functional/adm_spec.rb +50 -0
- data/spec/functional/apns2_spec.rb +232 -0
- data/spec/functional/apns_spec.rb +162 -0
- data/spec/functional/cli_spec.rb +36 -0
- data/spec/functional/embed_spec.rb +49 -0
- data/spec/functional/gcm_spec.rb +46 -0
- data/spec/functional/new_app_spec.rb +44 -0
- data/spec/functional/pushy_spec.rb +22 -0
- data/spec/functional/retry_spec.rb +42 -0
- data/spec/functional/synchronization_spec.rb +97 -0
- data/spec/functional/wpns_spec.rb +71 -0
- data/spec/functional_spec_helper.rb +32 -0
- data/spec/spec_helper.rb +69 -0
- data/spec/support/active_record_setup.rb +73 -0
- data/spec/support/cert_with_password.pem +90 -0
- data/spec/support/cert_without_password.pem +59 -0
- data/spec/support/config/database.yml +44 -0
- data/spec/support/simplecov_helper.rb +24 -0
- data/spec/support/simplecov_quality_formatter.rb +12 -0
- data/spec/tmp/.gitkeep +0 -0
- data/spec/unit/apns_feedback_spec.rb +28 -0
- data/spec/unit/client/active_record/adm/app_spec.rb +58 -0
- data/spec/unit/client/active_record/adm/notification_spec.rb +43 -0
- data/spec/unit/client/active_record/apns/app_spec.rb +29 -0
- data/spec/unit/client/active_record/apns/feedback_spec.rb +9 -0
- data/spec/unit/client/active_record/apns/notification_spec.rb +324 -0
- data/spec/unit/client/active_record/app_spec.rb +30 -0
- data/spec/unit/client/active_record/gcm/app_spec.rb +4 -0
- data/spec/unit/client/active_record/gcm/notification_spec.rb +67 -0
- data/spec/unit/client/active_record/notification_spec.rb +21 -0
- data/spec/unit/client/active_record/pushy/app_spec.rb +17 -0
- data/spec/unit/client/active_record/pushy/notification_spec.rb +65 -0
- data/spec/unit/client/active_record/wns/badge_notification_spec.rb +15 -0
- data/spec/unit/client/active_record/wns/raw_notification_spec.rb +26 -0
- data/spec/unit/client/active_record/wpns/app_spec.rb +4 -0
- data/spec/unit/client/active_record/wpns/notification_spec.rb +21 -0
- data/spec/unit/configuration_spec.rb +46 -0
- data/spec/unit/daemon/adm/delivery_spec.rb +253 -0
- data/spec/unit/daemon/apns/certificate_expired_error_spec.rb +11 -0
- data/spec/unit/daemon/apns/delivery_spec.rb +108 -0
- data/spec/unit/daemon/apns/feedback_receiver_spec.rb +119 -0
- data/spec/unit/daemon/app_runner_spec.rb +188 -0
- data/spec/unit/daemon/batch_spec.rb +169 -0
- data/spec/unit/daemon/delivery_error_spec.rb +13 -0
- data/spec/unit/daemon/delivery_spec.rb +51 -0
- data/spec/unit/daemon/dispatcher/http_spec.rb +34 -0
- data/spec/unit/daemon/dispatcher/tcp_spec.rb +32 -0
- data/spec/unit/daemon/dispatcher_loop_spec.rb +53 -0
- data/spec/unit/daemon/feeder_spec.rb +96 -0
- data/spec/unit/daemon/gcm/delivery_spec.rb +387 -0
- data/spec/unit/daemon/proc_title_spec.rb +11 -0
- data/spec/unit/daemon/pushy/delivery_spec.rb +159 -0
- data/spec/unit/daemon/retryable_error_spec.rb +14 -0
- data/spec/unit/daemon/service_config_methods_spec.rb +36 -0
- data/spec/unit/daemon/signal_handler_spec.rb +99 -0
- data/spec/unit/daemon/store/active_record/reconnectable_spec.rb +165 -0
- data/spec/unit/daemon/store/active_record_spec.rb +357 -0
- data/spec/unit/daemon/store/redis_spec.rb +365 -0
- data/spec/unit/daemon/tcp_connection_spec.rb +292 -0
- data/spec/unit/daemon/wns/delivery_spec.rb +176 -0
- data/spec/unit/daemon/wns/post_request_spec.rb +117 -0
- data/spec/unit/daemon/wpns/delivery_spec.rb +167 -0
- data/spec/unit/daemon_spec.rb +138 -0
- data/spec/unit/deprecatable_spec.rb +32 -0
- data/spec/unit/deprecation_spec.rb +15 -0
- data/spec/unit/embed_spec.rb +47 -0
- data/spec/unit/logger_spec.rb +127 -0
- data/spec/unit/notification_shared.rb +53 -0
- data/spec/unit/plugin_spec.rb +36 -0
- data/spec/unit/push_spec.rb +34 -0
- data/spec/unit/reflectable_spec.rb +27 -0
- data/spec/unit/reflection_collection_spec.rb +26 -0
- data/spec/unit/rpush_spec.rb +8 -0
- data/spec/unit_spec_helper.rb +26 -0
- metadata +709 -0
@@ -0,0 +1,214 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
require 'rpush/daemon/store/active_record/reconnectable'
|
4
|
+
|
5
|
+
module Rpush
|
6
|
+
module Daemon
|
7
|
+
module Store
|
8
|
+
class ActiveRecord
|
9
|
+
include Reconnectable
|
10
|
+
|
11
|
+
DEFAULT_MARK_OPTIONS = { persist: true }
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
reopen_log unless Rpush.config.embedded
|
15
|
+
end
|
16
|
+
|
17
|
+
def reopen_log
|
18
|
+
::ActiveRecord::Base.logger = Rpush.logger.internal_logger
|
19
|
+
end
|
20
|
+
|
21
|
+
def app(id)
|
22
|
+
Rpush::Client::ActiveRecord::App.find(id)
|
23
|
+
end
|
24
|
+
|
25
|
+
def all_apps
|
26
|
+
Rpush::Client::ActiveRecord::App.all
|
27
|
+
end
|
28
|
+
|
29
|
+
def deliverable_notifications(limit)
|
30
|
+
with_database_reconnect_and_retry do
|
31
|
+
notifications = Rpush::Client::ActiveRecord::Notification.transaction do
|
32
|
+
relation = ready_for_delivery
|
33
|
+
relation = relation.limit(limit)
|
34
|
+
ids = relation.lock(true).ids
|
35
|
+
unless ids.empty?
|
36
|
+
relation = Rpush::Client::ActiveRecord::Notification.where(id: ids)
|
37
|
+
# mark processing
|
38
|
+
relation.update_all(processing: true, updated_at: Time.now)
|
39
|
+
relation
|
40
|
+
else
|
41
|
+
[]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
notifications.to_a
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def mark_retryable(notification, deliver_after, opts = {})
|
50
|
+
opts = DEFAULT_MARK_OPTIONS.dup.merge(opts)
|
51
|
+
notification.processing = false
|
52
|
+
notification.retries += 1
|
53
|
+
notification.deliver_after = deliver_after
|
54
|
+
|
55
|
+
return unless opts[:persist]
|
56
|
+
|
57
|
+
with_database_reconnect_and_retry do
|
58
|
+
notification.save!(validate: false)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def mark_batch_retryable(notifications, deliver_after)
|
63
|
+
ids = []
|
64
|
+
notifications.each do |n|
|
65
|
+
mark_retryable(n, deliver_after, persist: false)
|
66
|
+
ids << n.id
|
67
|
+
end
|
68
|
+
mark_ids_retryable(ids, deliver_after)
|
69
|
+
end
|
70
|
+
|
71
|
+
def mark_ids_retryable(ids, deliver_after)
|
72
|
+
return if ids.empty?
|
73
|
+
|
74
|
+
with_database_reconnect_and_retry do
|
75
|
+
Rpush::Client::ActiveRecord::Notification.where(id: ids).update_all(['processing = ?, delivered = ?, delivered_at = ?, failed = ?, failed_at = ?, retries = retries + 1, deliver_after = ?', false, false, nil, false, nil, deliver_after])
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def mark_delivered(notification, time, opts = {})
|
80
|
+
opts = DEFAULT_MARK_OPTIONS.dup.merge(opts)
|
81
|
+
notification.processing = false
|
82
|
+
notification.delivered = true
|
83
|
+
notification.delivered_at = time
|
84
|
+
|
85
|
+
return unless opts[:persist]
|
86
|
+
|
87
|
+
with_database_reconnect_and_retry do
|
88
|
+
notification.save!(validate: false)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
def mark_batch_delivered(notifications)
|
93
|
+
return if notifications.empty?
|
94
|
+
|
95
|
+
now = Time.now
|
96
|
+
ids = []
|
97
|
+
notifications.each do |n|
|
98
|
+
mark_delivered(n, now, persist: false)
|
99
|
+
ids << n.id
|
100
|
+
end
|
101
|
+
with_database_reconnect_and_retry do
|
102
|
+
Rpush::Client::ActiveRecord::Notification.where(id: ids).update_all(['processing = ?, delivered = ?, delivered_at = ?', false, true, now])
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def mark_failed(notification, code, description, time, opts = {})
|
107
|
+
opts = DEFAULT_MARK_OPTIONS.dup.merge(opts)
|
108
|
+
notification.processing = false
|
109
|
+
notification.delivered = false
|
110
|
+
notification.delivered_at = nil
|
111
|
+
notification.failed = true
|
112
|
+
notification.failed_at = time
|
113
|
+
notification.error_code = code
|
114
|
+
notification.error_description = description
|
115
|
+
|
116
|
+
return unless opts[:persist]
|
117
|
+
|
118
|
+
with_database_reconnect_and_retry do
|
119
|
+
notification.save!(validate: false)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def mark_batch_failed(notifications, code, description)
|
124
|
+
now = Time.now
|
125
|
+
ids = []
|
126
|
+
notifications.each do |n|
|
127
|
+
mark_failed(n, code, description, now, persist: false)
|
128
|
+
ids << n.id
|
129
|
+
end
|
130
|
+
mark_ids_failed(ids, code, description, now)
|
131
|
+
end
|
132
|
+
|
133
|
+
def mark_ids_failed(ids, code, description, time)
|
134
|
+
return if ids.empty?
|
135
|
+
|
136
|
+
with_database_reconnect_and_retry do
|
137
|
+
Rpush::Client::ActiveRecord::Notification.where(id: ids).update_all(['processing = ?, delivered = ?, delivered_at = NULL, failed = ?, failed_at = ?, error_code = ?, error_description = ?', false, false, true, time, code, description])
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
def create_apns_feedback(failed_at, device_token, app)
|
142
|
+
with_database_reconnect_and_retry do
|
143
|
+
Rpush::Client::ActiveRecord::Apns::Feedback.create!(failed_at: failed_at,
|
144
|
+
device_token: device_token, app_id: app.id)
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def create_gcm_notification(attrs, data, registration_ids, deliver_after, app)
|
149
|
+
notification = Rpush::Client::ActiveRecord::Gcm::Notification.new
|
150
|
+
create_gcm_like_notification(notification, attrs, data, registration_ids, deliver_after, app)
|
151
|
+
end
|
152
|
+
|
153
|
+
def create_adm_notification(attrs, data, registration_ids, deliver_after, app)
|
154
|
+
notification = Rpush::Client::ActiveRecord::Adm::Notification.new
|
155
|
+
create_gcm_like_notification(notification, attrs, data, registration_ids, deliver_after, app)
|
156
|
+
end
|
157
|
+
|
158
|
+
def update_app(app)
|
159
|
+
with_database_reconnect_and_retry do
|
160
|
+
app.save!
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def update_notification(notification)
|
165
|
+
with_database_reconnect_and_retry do
|
166
|
+
notification.save!
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def release_connection
|
171
|
+
::ActiveRecord::Base.connection.close
|
172
|
+
rescue StandardError => e
|
173
|
+
Rpush.logger.error(e)
|
174
|
+
end
|
175
|
+
|
176
|
+
def pending_delivery_count
|
177
|
+
ready_for_delivery.count
|
178
|
+
end
|
179
|
+
|
180
|
+
def translate_integer_notification_id(id)
|
181
|
+
id
|
182
|
+
end
|
183
|
+
|
184
|
+
private
|
185
|
+
|
186
|
+
def create_gcm_like_notification(notification, attrs, data, registration_ids, deliver_after, app) # rubocop:disable ParameterLists
|
187
|
+
with_database_reconnect_and_retry do
|
188
|
+
notification.assign_attributes(attrs)
|
189
|
+
notification.data = data
|
190
|
+
notification.registration_ids = registration_ids
|
191
|
+
notification.deliver_after = deliver_after
|
192
|
+
notification.app = app
|
193
|
+
notification.save!
|
194
|
+
notification
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
def ready_for_delivery
|
199
|
+
relation = Rpush::Client::ActiveRecord::Notification.where('processing = ? AND delivered = ? AND failed = ? AND (deliver_after IS NULL OR deliver_after < ?)', false, false, false, Time.now)
|
200
|
+
relation.order('deliver_after ASC, created_at ASC')
|
201
|
+
end
|
202
|
+
|
203
|
+
def adapter_name
|
204
|
+
env = (defined?(Rails) && Rails.env) ? Rails.env : 'development'
|
205
|
+
config = ::ActiveRecord::Base.configurations[env]
|
206
|
+
return '' unless config
|
207
|
+
Hash[config.map { |k, v| [k.to_sym, v] }][:adapter]
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
Rpush::Daemon::Store::Interface.check(Rpush::Daemon::Store::ActiveRecord)
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Rpush
|
2
|
+
module Daemon
|
3
|
+
module Store
|
4
|
+
class Interface
|
5
|
+
PUBLIC_METHODS = [:deliverable_notifications, :mark_retryable,
|
6
|
+
:mark_batch_retryable, :mark_delivered, :mark_batch_delivered,
|
7
|
+
:mark_failed, :mark_batch_failed, :create_apns_feedback,
|
8
|
+
:create_gcm_notification, :create_adm_notification, :update_app,
|
9
|
+
:update_notification, :release_connection,
|
10
|
+
:all_apps, :app, :mark_ids_failed, :mark_ids_retryable,
|
11
|
+
:reopen_log, :pending_delivery_count, :translate_integer_notification_id]
|
12
|
+
|
13
|
+
def self.check(klass)
|
14
|
+
missing = PUBLIC_METHODS - klass.instance_methods.map(&:to_sym)
|
15
|
+
fail "#{klass} must implement #{missing.join(', ')}." if missing.any?
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
module Rpush
|
2
|
+
module Daemon
|
3
|
+
module Store
|
4
|
+
class Redis
|
5
|
+
DEFAULT_MARK_OPTIONS = { persist: true }
|
6
|
+
|
7
|
+
def app(app_id)
|
8
|
+
Rpush::Client::Redis::App.find(app_id)
|
9
|
+
end
|
10
|
+
|
11
|
+
def all_apps
|
12
|
+
Rpush::Client::Redis::App.all
|
13
|
+
end
|
14
|
+
|
15
|
+
def deliverable_notifications(limit)
|
16
|
+
retryable_ids = retryable_notification_ids
|
17
|
+
limit -= retryable_ids.size
|
18
|
+
pending_ids = limit > 0 ? pending_notification_ids(limit) : []
|
19
|
+
ids = retryable_ids + pending_ids
|
20
|
+
ids.map { |id| Rpush::Client::Redis::Notification.find(id) }
|
21
|
+
end
|
22
|
+
|
23
|
+
def mark_delivered(notification, time, opts = {})
|
24
|
+
opts = DEFAULT_MARK_OPTIONS.dup.merge(opts)
|
25
|
+
notification.delivered = true
|
26
|
+
notification.delivered_at = time
|
27
|
+
notification.save!(validate: false) if opts[:persist]
|
28
|
+
end
|
29
|
+
|
30
|
+
def mark_batch_delivered(notifications)
|
31
|
+
now = Time.now
|
32
|
+
notifications.each { |n| mark_delivered(n, now) }
|
33
|
+
end
|
34
|
+
|
35
|
+
def mark_failed(notification, code, description, time, opts = {})
|
36
|
+
opts = DEFAULT_MARK_OPTIONS.dup.merge(opts)
|
37
|
+
notification.delivered = false
|
38
|
+
notification.delivered_at = nil
|
39
|
+
notification.failed = true
|
40
|
+
notification.failed_at = time
|
41
|
+
notification.error_code = code
|
42
|
+
notification.error_description = description
|
43
|
+
notification.save!(validate: false) if opts[:persist]
|
44
|
+
end
|
45
|
+
|
46
|
+
def mark_batch_failed(notifications, code, description)
|
47
|
+
now = Time.now
|
48
|
+
notifications.each { |n| mark_failed(n, code, description, now) }
|
49
|
+
end
|
50
|
+
|
51
|
+
def mark_ids_failed(ids, code, description, time)
|
52
|
+
ids.each { |id| mark_failed(Rpush::Client::Redis::Notification.find(id), code, description, time) }
|
53
|
+
end
|
54
|
+
|
55
|
+
def mark_retryable(notification, deliver_after, opts = {})
|
56
|
+
opts = DEFAULT_MARK_OPTIONS.dup.merge(opts)
|
57
|
+
notification.delivered = false
|
58
|
+
notification.delivered_at = nil
|
59
|
+
notification.failed = false
|
60
|
+
notification.failed_at = nil
|
61
|
+
notification.retries += 1
|
62
|
+
notification.deliver_after = deliver_after
|
63
|
+
|
64
|
+
return unless opts[:persist]
|
65
|
+
|
66
|
+
notification.save!(validate: false)
|
67
|
+
namespace = Rpush::Client::Redis::Notification.absolute_retryable_namespace
|
68
|
+
Modis.with_connection do |redis|
|
69
|
+
redis.zadd(namespace, deliver_after.to_i, notification.id)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def mark_batch_retryable(notifications, deliver_after)
|
74
|
+
notifications.each { |n| mark_retryable(n, deliver_after) }
|
75
|
+
end
|
76
|
+
|
77
|
+
def mark_ids_retryable(ids, deliver_after)
|
78
|
+
ids.each { |id| mark_retryable(Rpush::Client::Redis::Notification.find(id), deliver_after) }
|
79
|
+
end
|
80
|
+
|
81
|
+
def create_apns_feedback(failed_at, device_token, app)
|
82
|
+
Rpush::Client::Redis::Apns::Feedback.create!(failed_at: failed_at, device_token: device_token, app_id: app.id)
|
83
|
+
end
|
84
|
+
|
85
|
+
def create_gcm_notification(attrs, data, registration_ids, deliver_after, app)
|
86
|
+
notification = Rpush::Client::Redis::Gcm::Notification.new
|
87
|
+
create_gcm_like_notification(notification, attrs, data, registration_ids, deliver_after, app)
|
88
|
+
end
|
89
|
+
|
90
|
+
def create_adm_notification(attrs, data, registration_ids, deliver_after, app)
|
91
|
+
notification = Rpush::Client::Redis::Adm::Notification.new
|
92
|
+
create_gcm_like_notification(notification, attrs, data, registration_ids, deliver_after, app)
|
93
|
+
end
|
94
|
+
|
95
|
+
def update_app(app)
|
96
|
+
app.save!
|
97
|
+
end
|
98
|
+
|
99
|
+
def update_notification(notification)
|
100
|
+
notification.save!
|
101
|
+
end
|
102
|
+
|
103
|
+
def release_connection
|
104
|
+
end
|
105
|
+
|
106
|
+
def reopen_log
|
107
|
+
end
|
108
|
+
|
109
|
+
def pending_delivery_count
|
110
|
+
Modis.with_connection do |redis|
|
111
|
+
pending = redis.zrange(Rpush::Client::Redis::Notification.absolute_pending_namespace, 0, -1)
|
112
|
+
retryable = redis.zrangebyscore(Rpush::Client::Redis::Notification.absolute_retryable_namespace, 0, Time.now.to_i)
|
113
|
+
|
114
|
+
pending.count + retryable.count
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def translate_integer_notification_id(id)
|
119
|
+
id
|
120
|
+
end
|
121
|
+
|
122
|
+
private
|
123
|
+
|
124
|
+
def create_gcm_like_notification(notification, attrs, data, registration_ids, deliver_after, app) # rubocop:disable ParameterLists
|
125
|
+
notification.assign_attributes(attrs)
|
126
|
+
notification.data = data
|
127
|
+
notification.registration_ids = registration_ids
|
128
|
+
notification.deliver_after = deliver_after
|
129
|
+
notification.app = app
|
130
|
+
notification.save!
|
131
|
+
notification
|
132
|
+
end
|
133
|
+
|
134
|
+
def retryable_notification_ids
|
135
|
+
retryable_ns = Rpush::Client::Redis::Notification.absolute_retryable_namespace
|
136
|
+
|
137
|
+
Modis.with_connection do |redis|
|
138
|
+
retryable_results = redis.multi do
|
139
|
+
now = Time.now.to_i
|
140
|
+
redis.zrangebyscore(retryable_ns, 0, now)
|
141
|
+
redis.zremrangebyscore(retryable_ns, 0, now)
|
142
|
+
end
|
143
|
+
|
144
|
+
retryable_results.first
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
def pending_notification_ids(limit)
|
149
|
+
limit = [0, limit - 1].max # 'zrange key 0 1' will return 2 values, not 1.
|
150
|
+
pending_ns = Rpush::Client::Redis::Notification.absolute_pending_namespace
|
151
|
+
|
152
|
+
Modis.with_connection do |redis|
|
153
|
+
pending_results = redis.multi do
|
154
|
+
redis.zrange(pending_ns, 0, limit)
|
155
|
+
redis.zremrangebyrank(pending_ns, 0, limit)
|
156
|
+
end
|
157
|
+
|
158
|
+
pending_results.first
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
Rpush::Daemon::Store::Interface.check(Rpush::Daemon::Store::Redis)
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module Rpush
|
2
|
+
module Daemon
|
3
|
+
module StringHelpers
|
4
|
+
def pluralize(count, singular, plural = nil)
|
5
|
+
if count == 1 || count =~ /^1(\.0+)?$/
|
6
|
+
word = singular
|
7
|
+
else
|
8
|
+
word = plural || singular.pluralize
|
9
|
+
end
|
10
|
+
|
11
|
+
"#{count || 0} #{word}"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module Rpush
|
2
|
+
module Daemon
|
3
|
+
class Synchronizer
|
4
|
+
extend Loggable
|
5
|
+
extend StringHelpers
|
6
|
+
|
7
|
+
APP_ATTRIBUTES_TO_CHECK = [:certificate, :environment, :auth_key, :client_id, :client_secret].freeze
|
8
|
+
|
9
|
+
def self.sync
|
10
|
+
apps = Rpush::Daemon.store.all_apps
|
11
|
+
apps.each { |app| sync_app(app) }
|
12
|
+
removed = AppRunner.app_ids - apps.map(&:id)
|
13
|
+
removed.each { |app_id| AppRunner.stop_app(app_id) }
|
14
|
+
|
15
|
+
ProcTitle.update
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.sync_app(app)
|
19
|
+
if !AppRunner.app_running?(app)
|
20
|
+
AppRunner.start_app(app)
|
21
|
+
elsif (changed_attrs = changed_attributes(app)).count > 0
|
22
|
+
changed_attrs_str = changed_attrs.map(&:to_s).join(", ")
|
23
|
+
log_info("[#{app.name}] #{changed_attrs_str} changed, restarting...")
|
24
|
+
AppRunner.stop_app(app.id)
|
25
|
+
AppRunner.start_app(app)
|
26
|
+
else
|
27
|
+
sync_dispatcher_count(app)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def self.sync_dispatcher_count(app)
|
32
|
+
num_dispatchers = AppRunner.num_dispatchers_for_app(app)
|
33
|
+
diff = num_dispatchers - app.connections
|
34
|
+
return if diff == 0
|
35
|
+
|
36
|
+
if diff > 0
|
37
|
+
AppRunner.decrement_dispatchers(app, diff)
|
38
|
+
start_stop_str = "Stopped"
|
39
|
+
else
|
40
|
+
AppRunner.increment_dispatchers(app, diff.abs)
|
41
|
+
start_stop_str = "Started"
|
42
|
+
end
|
43
|
+
|
44
|
+
num_dispatchers = AppRunner.num_dispatchers_for_app(app)
|
45
|
+
log_info("[#{app.name}] #{start_stop_str} #{pluralize(diff.abs, 'dispatcher')}. #{num_dispatchers} running.")
|
46
|
+
end
|
47
|
+
|
48
|
+
def self.changed_attributes(app)
|
49
|
+
APP_ATTRIBUTES_TO_CHECK.select { |attr| attribute_changed?(app, attr) }
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.attribute_changed?(app, attr)
|
53
|
+
if app.respond_to?(attr)
|
54
|
+
old_app = AppRunner.app_with_id(app.id)
|
55
|
+
app.send(attr) != old_app.send(attr)
|
56
|
+
else
|
57
|
+
false
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,190 @@
|
|
1
|
+
module Rpush
|
2
|
+
module Daemon
|
3
|
+
class TcpConnectionError < StandardError; end
|
4
|
+
|
5
|
+
class TcpConnection
|
6
|
+
include Reflectable
|
7
|
+
include Loggable
|
8
|
+
|
9
|
+
OSX_TCP_KEEPALIVE = 0x10 # Defined in <netinet/tcp.h>
|
10
|
+
KEEPALIVE_INTERVAL = 5
|
11
|
+
KEEPALIVE_IDLE = 5
|
12
|
+
KEEPALIVE_MAX_FAIL_PROBES = 1
|
13
|
+
TCP_ERRORS = [SystemCallError, OpenSSL::OpenSSLError, IOError]
|
14
|
+
|
15
|
+
attr_accessor :last_touch
|
16
|
+
attr_reader :host, :port
|
17
|
+
|
18
|
+
def self.idle_period
|
19
|
+
30.minutes
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(app, host, port)
|
23
|
+
@app = app
|
24
|
+
@host = host
|
25
|
+
@port = port
|
26
|
+
@certificate = app.certificate
|
27
|
+
@password = app.password
|
28
|
+
@connected = false
|
29
|
+
@connection_callbacks = []
|
30
|
+
touch
|
31
|
+
end
|
32
|
+
|
33
|
+
def on_connect(&blk)
|
34
|
+
raise 'already connected' if @connected
|
35
|
+
@connection_callbacks << blk
|
36
|
+
end
|
37
|
+
|
38
|
+
def connect
|
39
|
+
@ssl_context = setup_ssl_context
|
40
|
+
@tcp_socket, @ssl_socket = connect_socket
|
41
|
+
@connected = true
|
42
|
+
|
43
|
+
@connection_callbacks.each do |blk|
|
44
|
+
begin
|
45
|
+
blk.call
|
46
|
+
rescue StandardError => e
|
47
|
+
log_error(e)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
@connection_callbacks.clear
|
52
|
+
end
|
53
|
+
|
54
|
+
def close
|
55
|
+
@ssl_socket.close if @ssl_socket
|
56
|
+
@tcp_socket.close if @tcp_socket
|
57
|
+
rescue IOError # rubocop:disable HandleExceptions
|
58
|
+
end
|
59
|
+
|
60
|
+
def read(num_bytes)
|
61
|
+
@ssl_socket.read(num_bytes) if @ssl_socket
|
62
|
+
end
|
63
|
+
|
64
|
+
def select(timeout)
|
65
|
+
IO.select([@ssl_socket], nil, nil, timeout) if @ssl_socket
|
66
|
+
end
|
67
|
+
|
68
|
+
def write(data)
|
69
|
+
connect unless @connected
|
70
|
+
reconnect_idle if idle_period_exceeded?
|
71
|
+
|
72
|
+
retry_count = 0
|
73
|
+
|
74
|
+
begin
|
75
|
+
write_data(data)
|
76
|
+
rescue *TCP_ERRORS => e
|
77
|
+
retry_count += 1
|
78
|
+
|
79
|
+
if retry_count == 1
|
80
|
+
log_error("Lost connection to #{@host}:#{@port} (#{e.class.name}, #{e.message}), reconnecting...")
|
81
|
+
reflect(:tcp_connection_lost, @app, e)
|
82
|
+
end
|
83
|
+
|
84
|
+
if retry_count <= 3
|
85
|
+
reconnect_with_rescue
|
86
|
+
sleep 1
|
87
|
+
retry
|
88
|
+
else
|
89
|
+
raise TcpConnectionError, "#{@app.name} tried #{retry_count - 1} times to reconnect but failed (#{e.class.name}, #{e.message})."
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def reconnect_with_rescue
|
95
|
+
reconnect
|
96
|
+
rescue StandardError => e
|
97
|
+
log_error(e)
|
98
|
+
end
|
99
|
+
|
100
|
+
def reconnect
|
101
|
+
close
|
102
|
+
@tcp_socket, @ssl_socket = connect_socket
|
103
|
+
end
|
104
|
+
|
105
|
+
protected
|
106
|
+
|
107
|
+
def reconnect_idle
|
108
|
+
log_info("Idle period exceeded, reconnecting...")
|
109
|
+
reconnect
|
110
|
+
end
|
111
|
+
|
112
|
+
def idle_period_exceeded?
|
113
|
+
Time.now - last_touch > self.class.idle_period
|
114
|
+
end
|
115
|
+
|
116
|
+
def write_data(data)
|
117
|
+
@ssl_socket.write(data)
|
118
|
+
@ssl_socket.flush
|
119
|
+
touch
|
120
|
+
end
|
121
|
+
|
122
|
+
def touch
|
123
|
+
self.last_touch = Time.now
|
124
|
+
end
|
125
|
+
|
126
|
+
def setup_ssl_context
|
127
|
+
ssl_context = OpenSSL::SSL::SSLContext.new
|
128
|
+
ssl_context.key = OpenSSL::PKey::RSA.new(@certificate, @password)
|
129
|
+
ssl_context.cert = OpenSSL::X509::Certificate.new(@certificate)
|
130
|
+
ssl_context
|
131
|
+
end
|
132
|
+
|
133
|
+
def connect_socket
|
134
|
+
touch
|
135
|
+
check_certificate_expiration
|
136
|
+
|
137
|
+
tcp_socket = TCPSocket.new(@host, @port)
|
138
|
+
tcp_socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
|
139
|
+
tcp_socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, true)
|
140
|
+
|
141
|
+
# Linux
|
142
|
+
if [:SOL_TCP, :TCP_KEEPIDLE, :TCP_KEEPINTVL, :TCP_KEEPCNT].all? { |c| Socket.const_defined?(c) }
|
143
|
+
tcp_socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPIDLE, KEEPALIVE_IDLE)
|
144
|
+
tcp_socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, KEEPALIVE_INTERVAL)
|
145
|
+
tcp_socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, KEEPALIVE_MAX_FAIL_PROBES)
|
146
|
+
end
|
147
|
+
|
148
|
+
# OSX
|
149
|
+
if RUBY_PLATFORM =~ /darwin/
|
150
|
+
tcp_socket.setsockopt(Socket::IPPROTO_TCP, OSX_TCP_KEEPALIVE, KEEPALIVE_IDLE)
|
151
|
+
end
|
152
|
+
|
153
|
+
ssl_socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, @ssl_context)
|
154
|
+
ssl_socket.sync = true
|
155
|
+
ssl_socket.connect
|
156
|
+
[tcp_socket, ssl_socket]
|
157
|
+
rescue *TCP_ERRORS => error
|
158
|
+
if error.message =~ /certificate revoked/i
|
159
|
+
log_error('Certificate has been revoked.')
|
160
|
+
reflect(:ssl_certificate_revoked, @app, error)
|
161
|
+
end
|
162
|
+
raise TcpConnectionError, "#{error.class.name}, #{error.message}"
|
163
|
+
end
|
164
|
+
|
165
|
+
def check_certificate_expiration
|
166
|
+
cert = @ssl_context.cert
|
167
|
+
if certificate_expired?
|
168
|
+
log_error(certificate_msg('expired'))
|
169
|
+
raise Rpush::CertificateExpiredError.new(@app, cert.not_after)
|
170
|
+
elsif certificate_expires_soon?
|
171
|
+
log_warn(certificate_msg('will expire'))
|
172
|
+
reflect(:ssl_certificate_will_expire, @app, cert.not_after)
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def certificate_msg(msg)
|
177
|
+
time = @ssl_context.cert.not_after.utc.strftime('%Y-%m-%d %H:%M:%S UTC')
|
178
|
+
"Certificate #{msg} at #{time}."
|
179
|
+
end
|
180
|
+
|
181
|
+
def certificate_expired?
|
182
|
+
@ssl_context.cert.not_after && @ssl_context.cert.not_after.utc < Time.now.utc
|
183
|
+
end
|
184
|
+
|
185
|
+
def certificate_expires_soon?
|
186
|
+
@ssl_context.cert.not_after && @ssl_context.cert.not_after.utc < (Time.now + 1.month).utc
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Rpush
|
2
|
+
module Daemon
|
3
|
+
module Wns
|
4
|
+
class BadgeRequest
|
5
|
+
def self.create(notification, access_token)
|
6
|
+
body = BadgeRequestPayload.new(notification).to_xml
|
7
|
+
uri = URI.parse(notification.uri)
|
8
|
+
post = Net::HTTP::Post.new(
|
9
|
+
uri.request_uri,
|
10
|
+
"Content-Length" => body.length.to_s,
|
11
|
+
"Content-Type" => "text/xml",
|
12
|
+
"X-WNS-Type" => "wns/badge",
|
13
|
+
"X-WNS-RequestForStatus" => "true",
|
14
|
+
"Authorization" => "Bearer #{access_token}"
|
15
|
+
)
|
16
|
+
post.body = body
|
17
|
+
post
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class BadgeRequestPayload
|
22
|
+
def initialize(notification)
|
23
|
+
@badge = notification.badge || 0
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_xml
|
27
|
+
"<badge value=\"#{@badge}\"/>"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|