mirror of
https://github.com/discourse/discourse.git
synced 2024-11-23 09:26:54 -06:00
f1a43f2319
We're seeing unhandled errors in production when web push notifications are failing with an SSL error. This is happening for a few users, but generating a large amount of log noise due to the sheer number of notifications. This adds handling of SSL errors in two places: 1. In FinalDestination::HTTP, this is handled the same as a timeout error, and gives a chance to recover. 2. In PushNotificationPusher. This will cause the notification to retry a number of times, and if it keeps failing, disable push notifications for the user. (Existing behaviour.) I wanted to wrap the SSL error in e.g. WebPush::RequestError, but the gem doesn't have request error handling, so didn't want to have the freedom patch diverge from the gem as well. Instead just propagating the raw SSL error.
168 lines
5.0 KiB
Ruby
168 lines
5.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class PushNotificationPusher
|
|
TOKEN_VALID_FOR_SECONDS ||= 5 * 60
|
|
CONNECTION_TIMEOUT_SECONDS = 5
|
|
|
|
def self.push(user, payload)
|
|
message = nil
|
|
I18n.with_locale(user.effective_locale) do
|
|
notification_icon_name = Notification.types[payload[:notification_type]]
|
|
if !File.exist?(
|
|
File.expand_path(
|
|
"../../app/assets/images/push-notifications/#{notification_icon_name}.png",
|
|
__dir__,
|
|
),
|
|
)
|
|
notification_icon_name = "discourse"
|
|
end
|
|
notification_icon =
|
|
ActionController::Base.helpers.image_url("push-notifications/#{notification_icon_name}.png")
|
|
|
|
message = {
|
|
title:
|
|
payload[:translated_title] ||
|
|
I18n.t(
|
|
"discourse_push_notifications.popup.#{Notification.types[payload[:notification_type]]}",
|
|
site_title: SiteSetting.title,
|
|
topic: payload[:topic_title],
|
|
username: payload[:username],
|
|
),
|
|
body: payload[:excerpt],
|
|
badge: get_badge,
|
|
icon: notification_icon,
|
|
tag: payload[:tag] || "#{Discourse.current_hostname}-#{payload[:topic_id]}",
|
|
base_url: Discourse.base_url,
|
|
url: payload[:post_url],
|
|
hide_when_active: true,
|
|
}
|
|
|
|
subscriptions(user).each { |subscription| send_notification(user, subscription, message) }
|
|
end
|
|
|
|
message
|
|
end
|
|
|
|
def self.subscriptions(user)
|
|
user.push_subscriptions
|
|
end
|
|
|
|
def self.clear_subscriptions(user)
|
|
user.push_subscriptions.clear
|
|
end
|
|
|
|
def self.subscribe(user, push_params, send_confirmation)
|
|
data = push_params.to_json
|
|
subscriptions = PushSubscription.where(user: user, data: data)
|
|
subscriptions_count = subscriptions.count
|
|
|
|
new_subscription =
|
|
if subscriptions_count > 1
|
|
subscriptions.destroy_all
|
|
PushSubscription.create!(user: user, data: data)
|
|
elsif subscriptions_count == 0
|
|
PushSubscription.create!(user: user, data: data)
|
|
end
|
|
|
|
if send_confirmation == "true"
|
|
message = {
|
|
title:
|
|
I18n.t("discourse_push_notifications.popup.confirm_title", site_title: SiteSetting.title),
|
|
body: I18n.t("discourse_push_notifications.popup.confirm_body"),
|
|
icon: ActionController::Base.helpers.image_url("push-notifications/check.png"),
|
|
badge: get_badge,
|
|
tag: "#{Discourse.current_hostname}-subscription",
|
|
}
|
|
|
|
send_notification(user, new_subscription, message)
|
|
end
|
|
end
|
|
|
|
def self.unsubscribe(user, subscription)
|
|
PushSubscription.find_by(user: user, data: subscription.to_json)&.destroy!
|
|
end
|
|
|
|
def self.get_badge
|
|
if (url = SiteSetting.site_push_notifications_icon_url).present?
|
|
url
|
|
else
|
|
ActionController::Base.helpers.image_url("push-notifications/discourse.png")
|
|
end
|
|
end
|
|
|
|
MAX_ERRORS ||= 3
|
|
MIN_ERROR_DURATION ||= 86_400 # 1 day
|
|
|
|
def self.handle_generic_error(subscription, error, user, endpoint, message)
|
|
subscription.error_count += 1
|
|
subscription.first_error_at ||= Time.zone.now
|
|
|
|
delta = Time.zone.now - subscription.first_error_at
|
|
if subscription.error_count >= MAX_ERRORS && delta > MIN_ERROR_DURATION
|
|
subscription.destroy!
|
|
else
|
|
subscription.save!
|
|
end
|
|
|
|
Discourse.warn_exception(
|
|
error,
|
|
message: "Failed to send push notification",
|
|
env: {
|
|
user_id: user.id,
|
|
endpoint: endpoint,
|
|
message: message.to_json,
|
|
},
|
|
)
|
|
end
|
|
|
|
def self.send_notification(user, subscription, message)
|
|
parsed_data = subscription.parsed_data
|
|
|
|
endpoint = parsed_data["endpoint"]
|
|
p256dh = parsed_data.dig("keys", "p256dh")
|
|
auth = parsed_data.dig("keys", "auth")
|
|
|
|
if (endpoint.blank? || p256dh.blank? || auth.blank?)
|
|
subscription.destroy!
|
|
return
|
|
end
|
|
|
|
begin
|
|
WebPush.payload_send(
|
|
endpoint: endpoint,
|
|
message: message.to_json,
|
|
p256dh: p256dh,
|
|
auth: auth,
|
|
vapid: {
|
|
subject: Discourse.base_url,
|
|
public_key: SiteSetting.vapid_public_key,
|
|
private_key: SiteSetting.vapid_private_key,
|
|
expiration: TOKEN_VALID_FOR_SECONDS,
|
|
},
|
|
open_timeout: CONNECTION_TIMEOUT_SECONDS,
|
|
read_timeout: CONNECTION_TIMEOUT_SECONDS,
|
|
ssl_timeout: CONNECTION_TIMEOUT_SECONDS,
|
|
)
|
|
|
|
if subscription.first_error_at || subscription.error_count != 0
|
|
subscription.update_columns(error_count: 0, first_error_at: nil)
|
|
end
|
|
rescue WebPush::ExpiredSubscription
|
|
subscription.destroy!
|
|
rescue WebPush::ResponseError => e
|
|
if e.response.message == "MismatchSenderId"
|
|
subscription.destroy!
|
|
else
|
|
handle_generic_error(subscription, e, user, endpoint, message)
|
|
end
|
|
rescue Timeout::Error => e
|
|
handle_generic_error(subscription, e, user, endpoint, message)
|
|
rescue OpenSSL::SSL::SSLError => e
|
|
handle_generic_error(subscription, e, user, endpoint, message)
|
|
end
|
|
end
|
|
|
|
private_class_method :send_notification
|
|
private_class_method :handle_generic_error
|
|
end
|