From e59a994a0c18172da70e4aa91e4e5148d6b4e941 Mon Sep 17 00:00:00 2001 From: Jason Zimdars Date: Mon, 14 Jul 2025 19:57:30 -0500 Subject: [PATCH] Add db table and models --- app/models/push.rb | 5 + app/models/push/subscription.rb | 7 + app/models/user.rb | 1 + app/views/layouts/shared/_head.html.erb | 1 + config/credentials/development.yml.enc | 2 +- config/initializers/vapid.rb | 4 + config/initializers/web_push.rb | 44 +++++ .../20250714222221_add_push_subscriptions.rb | 17 ++ db/schema.rb | 18 +- db/schema_cache.yml | 168 +++++++++++++++--- lib/web_push/notification.rb | 29 +++ lib/web_push/pool.rb | 56 ++++++ 12 files changed, 327 insertions(+), 25 deletions(-) create mode 100644 app/models/push.rb create mode 100644 app/models/push/subscription.rb create mode 100644 config/initializers/vapid.rb create mode 100644 config/initializers/web_push.rb create mode 100644 db/migrate/20250714222221_add_push_subscriptions.rb create mode 100644 lib/web_push/notification.rb create mode 100644 lib/web_push/pool.rb diff --git a/app/models/push.rb b/app/models/push.rb new file mode 100644 index 000000000..d0ec04a37 --- /dev/null +++ b/app/models/push.rb @@ -0,0 +1,5 @@ +module Push + def self.table_name_prefix + "push_" + end +end \ No newline at end of file diff --git a/app/models/push/subscription.rb b/app/models/push/subscription.rb new file mode 100644 index 000000000..ebc12c70d --- /dev/null +++ b/app/models/push/subscription.rb @@ -0,0 +1,7 @@ +class Push::Subscription < ApplicationRecord + belongs_to :user + + def notification(**params) + WebPush::Notification.new(**params, badge: user.notifications.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 4c1370619..dc7f13aad 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,6 +17,7 @@ class User < ApplicationRecord has_many :pins, dependent: :destroy has_many :pinned_cards, through: :pins, source: :card has_many :commands, dependent: :destroy + has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all normalizes :email_address, with: ->(value) { value.strip.downcase } diff --git a/app/views/layouts/shared/_head.html.erb b/app/views/layouts/shared/_head.html.erb index b79c9ba58..0922c2de4 100644 --- a/app/views/layouts/shared/_head.html.erb +++ b/app/views/layouts/shared/_head.html.erb @@ -9,6 +9,7 @@ <%= csrf_meta_tags %> <%= csp_meta_tag %> <%= tag.meta name: "current-user-id", content: Current.user.id if Current.user %> + <%= tag.meta name: "vapid-public-key", content: Rails.configuration.x.vapid.public_key %> <% turbo_refreshes_with method: :morph, scroll: :preserve %> diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc index ce2cc3c0f..1286f0d86 100644 --- a/config/credentials/development.yml.enc +++ b/config/credentials/development.yml.enc @@ -1 +1 @@ -hHi9VfqfZI57GYR5r75Rh00L3FYeoUgsdtCgBzJGdPhhGZLGlCgUAcY6tgVhqhgM/u5WxyPAzmoV+bbQKb0wO0U6KUmrDKDv9uRnNSUjXGYsg8gWdTUTfx7WBFOFawV3p1Dsm7zM1oUJbRWgy2TiMFl5kP9CN4Lk78n5a1/Y7mnx/wmzwUyQv+BUFJ0UKCfRmWieaNoLkm5kWwyepWHaSRU5EVda6J9VtuwP3hISIzncXm0jpr9r/tJm8flgUR3o2STIVHVgg+YcWz0j3st5RMv7i3D2OHfNGx84vsf3LNlsiLGB1ZU0IsmchLOSqPmlDK2oiB7UKepTPthGUFzNmcDG4LpUQfNwn7B8P7by0zadVefdLSB3kFCNLX+KCAbcjHPlWufkagwHo+dV+iIVACC+hfWl3L/IouqYS0ln2XCBDz6A8lI8PHeGaJe9UhfA5JEuJLRPui8znoq7LrKo4/oBB7ArKdHz/W/oHJA9+aLHcLOq9TPSExAsGi1wcjdgF0nk5mB9iX+az3//1zM64rRz8EyzNkBg1I6PV6wpzROLubttv1+I5nRVe4BernNfVJPi/hbvrlrcZmouU32oOzd2aZuPL5/H8gBhMOPlN25Rr51XCziaczgdEfOwTO8da6r7pxsJDkrvKi+Ju6efxhZ6GeotWJuEUtQtWhHnzvhcJAXfUtJf6A==--LWtZauLkWq+tD+4L--mkMcqYebO9TDi6RTRNUQvA== \ No newline at end of file +kK/zGHEsgRGIoP27w3Pvm2UXQTyxJFDlPTgsX0OwazJ32RB118DfCJV7dM9OBLdTpPu9DowQsSjObX893BuQgplwYTAuq9zndq7p1qsxzwv/IMgGeo7Nv+h90lnQVWrp/vwfEBZ/BZ4n448Q+TndWLJzUIL1So5dkMj/swvB2ORE8X+2mkOSa9y2yIx51ro0UZaBOfphy+fZUUBeCK/yTDcqXkgPc4wXpZ3de5XGed5JjeMF2PaCoi7plF2SW8XBsThipmG7N92ojkfiEH7zX12ZUjd3UFTqo2xGgM5NAivbHIev9RcsIjXFuJu/T3I6PbFMEw4cSFCBFCesag4H+74DVkT+3bdWF9H+Ag1/ePYzFDygjWaQDehGDUbeXoz7XehYhLy8hErUWnmh4kbekt9in6EskayBZkyr/Mq2MVBydOywY4oVQuk1bEnM/Akwq/6sVYbtupXfIUIETHpSTM/IyQM0ht4c6cjNKZVM48Cr6+2hgdFN4oEjl5J0FDUNNaI3vfOebNtLv4lcF6f4O8kJ5KNe+iKbD376jSlXfeBcoj9O7kStpGGye54/d/dKH6UVbNQReBf9FbqNxoPKYIN2Uc+Bs0Kgo25FHISLFJstimQcgdJ71djRs6lpKCKX0QzKdt6QGpJJlsBZ7e8T89m71WggX3YSm2aW4aV7tnQmFlJODOcjXAAHaAB4kL1browf9/4tiPnoWFXoaISUqzmYn8n4Ao8xjW2G2bK75gEruUhDaSMBgXNLgG/8isN6Tdw4bSnluNdY6OHIcFDk7GPEbBvUs4C11P30uDLuztbEHTahbmXhkhZ4tpLLpy2y5dcYY37gvFIqsbHCXR5PTA2JrgrCAeUoF+f2sPn/USicJIFBNclppULoF7VnQJyRiwdUXN/T1ZG/CZH769GILEEG--XCcg7yRJcGpr4qyd--pE4nqwEeUck8ZAgJrR3B8A== \ No newline at end of file diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb new file mode 100644 index 000000000..72f4d3759 --- /dev/null +++ b/config/initializers/vapid.rb @@ -0,0 +1,4 @@ +Rails.application.configure do + config.x.vapid.private_key = ENV.fetch("VAPID_PRIVATE_KEY", Rails.application.credentials.dig(:vapid, :private_key)) + config.x.vapid.public_key = ENV.fetch("VAPID_PUBLIC_KEY", Rails.application.credentials.dig(:vapid, :public_key)) +end \ No newline at end of file diff --git a/config/initializers/web_push.rb b/config/initializers/web_push.rb new file mode 100644 index 000000000..ac960468e --- /dev/null +++ b/config/initializers/web_push.rb @@ -0,0 +1,44 @@ +require "web-push" +require "web_push/pool" +require "web_push/notification" + +Rails.application.configure do + config.x.web_push_pool = WebPush::Pool.new( + invalid_subscription_handler: ->(subscription_id) do + Rails.application.executor.wrap do + Rails.logger.info "Destroying push subscription: #{subscription_id}" + Push::Subscription.find_by(id: subscription_id)&.destroy + end + end + ) + + at_exit { config.x.web_push_pool.shutdown } +end + +module WebPush::PersistentRequest + def perform + if @options[:connection] + http = @options[:connection] + else + http = Net::HTTP.new(uri.host, uri.port, *proxy_options) + http.use_ssl = true + http.ssl_timeout = @options[:ssl_timeout] unless @options[:ssl_timeout].nil? + http.open_timeout = @options[:open_timeout] unless @options[:open_timeout].nil? + http.read_timeout = @options[:read_timeout] unless @options[:read_timeout].nil? + end + + req = Net::HTTP::Post.new(uri.request_uri, headers) + req.body = body + + if http.is_a?(Net::HTTP::Persistent) + response = http.request uri, req + else + resp = http.request(req) + verify_response(resp) + end + + resp + end +end + +WebPush::Request.prepend WebPush::PersistentRequest diff --git a/db/migrate/20250714222221_add_push_subscriptions.rb b/db/migrate/20250714222221_add_push_subscriptions.rb new file mode 100644 index 000000000..5dbaa0dbc --- /dev/null +++ b/db/migrate/20250714222221_add_push_subscriptions.rb @@ -0,0 +1,17 @@ +class AddPushSubscriptions < ActiveRecord::Migration[8.1] + def change + create_table :push_subscriptions do |t| + t.references :user, null: false, foreign_key: true + t.string :endpoint + t.string :p256dh_key + t.string :auth_key + t.string :user_agent + t.timestamps + + t.index [:user_id, :endpoint], unique: true + t.index :endpoint + t.index :user_agent + t.index ["endpoint", "p256dh_key", "auth_key"] + end + end +end \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index 7f5461614..8e8049180 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_07_03_193928) do +ActiveRecord::Schema[8.1].define(version: 2025_07_14_222221) do create_table "accesses", force: :cascade do |t| t.datetime "accessed_at" t.integer "collection_id", null: false @@ -299,6 +299,21 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_03_193928) do t.index ["user_id"], name: "index_pins_on_user_id" end + create_table "push_subscriptions", force: :cascade do |t| + t.string "auth_key" + t.datetime "created_at", null: false + t.string "endpoint" + t.string "p256dh_key" + t.datetime "updated_at", null: false + t.string "user_agent" + t.integer "user_id", null: false + t.index ["endpoint", "p256dh_key", "auth_key"], name: "idx_on_endpoint_p256dh_key_auth_key_7553014576" + t.index ["endpoint"], name: "index_push_subscriptions_on_endpoint" + t.index ["user_agent"], name: "index_push_subscriptions_on_user_agent" + t.index ["user_id", "endpoint"], name: "index_push_subscriptions_on_user_id_and_endpoint", unique: true + t.index ["user_id"], name: "index_push_subscriptions_on_user_id" + end + create_table "reactions", force: :cascade do |t| t.integer "comment_id", null: false t.string "content", limit: 16, null: false @@ -416,6 +431,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_03_193928) do add_foreign_key "notifications", "users", column: "creator_id" add_foreign_key "pins", "cards" add_foreign_key "pins", "users" + add_foreign_key "push_subscriptions", "users" add_foreign_key "search_queries", "users" add_foreign_key "sessions", "users" add_foreign_key "steps", "cards" diff --git a/db/schema_cache.yml b/db/schema_cache.yml index 1f58a58a8..45893d5c7 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -451,7 +451,7 @@ columns: default_function: collation: comment: - - &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: title cast_type: *7 @@ -532,11 +532,11 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: all_access - cast_type: &30 !ruby/object:ActiveModel::Type::Boolean + cast_type: &31 !ruby/object:ActiveModel::Type::Boolean precision: scale: limit: - sql_type_metadata: &31 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata + sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata sql_type: boolean type: :boolean limit: @@ -756,7 +756,7 @@ columns: comment: filters_tags: - *19 - - &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + - &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: tag_id cast_type: *3 @@ -843,6 +843,51 @@ columns: - *6 - *9 - *25 + push_subscriptions: + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: auth_key + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *5 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: endpoint + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *6 + - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: p256dh_key + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *9 + - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column + auto_increment: + name: user_agent + cast_type: *7 + sql_type_metadata: *8 + 'null': true + default: + default_function: + collation: + comment: + - *25 reactions: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: @@ -941,24 +986,15 @@ columns: collation: comment: - *9 - - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column - auto_increment: - name: user_agent - cast_type: *7 - sql_type_metadata: *8 - 'null': true - default: - default_function: - collation: - comment: + - *30 - *25 steps: - *22 - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: completed - cast_type: *30 - sql_type_metadata: *31 + cast_type: *31 + sql_type_metadata: *32 'null': false default: false default_function: @@ -981,19 +1017,19 @@ columns: - *22 - *5 - *6 - - *32 + - *33 - *9 tags: - *5 - *6 - - *33 + - *34 - *9 users: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: active - cast_type: *30 - sql_type_metadata: *31 + cast_type: *31 + sql_type_metadata: *32 'null': false default: true default_function: @@ -1052,8 +1088,8 @@ columns: - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column auto_increment: name: watching - cast_type: *30 - sql_type_metadata: *31 + cast_type: *31 + sql_type_metadata: *32 'null': false default: true default_function: @@ -1121,6 +1157,7 @@ primary_keys: mentions: id notifications: id pins: id + push_subscriptions: id reactions: id schema_migrations: version search_queries: id @@ -1165,6 +1202,7 @@ data_sources: mentions: true notifications: true pins: true + push_subscriptions: true reactions: true schema_migrations: true search_queries: true @@ -2244,6 +2282,90 @@ indexes: nulls_not_distinct: comment: valid: true + push_subscriptions: + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: push_subscriptions + name: idx_on_endpoint_p256dh_key_auth_key_7553014576 + unique: false + columns: + - endpoint + - p256dh_key + - auth_key + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: push_subscriptions + name: index_push_subscriptions_on_endpoint + unique: false + columns: + - endpoint + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: push_subscriptions + name: index_push_subscriptions_on_user_agent + unique: false + columns: + - user_agent + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: push_subscriptions + name: index_push_subscriptions_on_user_id + unique: false + columns: + - user_id + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: push_subscriptions + name: index_push_subscriptions_on_user_id_and_endpoint + unique: true + columns: + - user_id + - endpoint + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true reactions: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition table: reactions @@ -2516,4 +2638,4 @@ indexes: comment: valid: true workflows: [] -version: 20250703193928 +version: 20250714222221 diff --git a/lib/web_push/notification.rb b/lib/web_push/notification.rb new file mode 100644 index 000000000..2ad156c1c --- /dev/null +++ b/lib/web_push/notification.rb @@ -0,0 +1,29 @@ +class WebPush::Notification + def initialize(title:, body:, path:, badge:, endpoint:, p256dh_key:, auth_key:) + @title, @body, @path, @badge = title, body, path, badge + @endpoint, @p256dh_key, @auth_key = endpoint, p256dh_key, auth_key + end + + def deliver(connection: nil) + WebPush.payload_send \ + message: encoded_message, + endpoint: @endpoint, p256dh: @p256dh_key, auth: @auth_key, + vapid: vapid_identification, + connection: connection, + urgency: "high" + end + + private + def vapid_identification + { subject: "mailto:support@37signals.com" }.merge \ + Rails.configuration.x.vapid.symbolize_keys + end + + def encoded_message + JSON.generate title: @title, options: { body: @body, icon: icon_path, data: { path: @path, badge: @badge } } + end + + def icon_path + Rails.application.routes.url_helpers.account_logo_path + end +end diff --git a/lib/web_push/pool.rb b/lib/web_push/pool.rb new file mode 100644 index 000000000..2a0533ae3 --- /dev/null +++ b/lib/web_push/pool.rb @@ -0,0 +1,56 @@ +# This is in lib so we can use it in a thread pool without the Rails executor +class WebPush::Pool + attr_reader :delivery_pool, :invalidation_pool, :connection, :invalid_subscription_handler + + def initialize(invalid_subscription_handler:) + @delivery_pool = Concurrent::ThreadPoolExecutor.new(max_threads: 50, queue_size: 10000) + @invalidation_pool = Concurrent::FixedThreadPool.new(1) + @connection = Net::HTTP::Persistent.new(name: "web_push", pool_size: 150) + @invalid_subscription_handler = invalid_subscription_handler + end + + def queue(payload, subscriptions) + subscriptions.find_each do |subscription| + deliver_later(payload, subscription) + end + end + + def shutdown + connection.shutdown + shutdown_pool(delivery_pool) + shutdown_pool(invalidation_pool) + end + + private + def deliver_later(payload, subscription) + # Ensure any AR operations happen before we post to the thread pool + notification = subscription.notification(**payload) + subscription_id = subscription.id + + delivery_pool.post do + deliver(notification, subscription_id) + rescue Exception => e + Rails.logger.error "Error in WebPush::Pool.deliver: #{e.class} #{e.message}" + end + rescue Concurrent::RejectedExecutionError + end + + def deliver(notification, id) + notification.deliver(connection: connection) + rescue WebPush::ExpiredSubscription, OpenSSL::OpenSSLError => ex + invalidate_subscription_later(id) if invalid_subscription_handler + end + + def invalidate_subscription_later(id) + invalidation_pool.post do + invalid_subscription_handler.call(id) + rescue Exception => e + Rails.logger.error "Error in WebPush::Pool.invalid_subscription_handler: #{e.class} #{e.message}" + end + end + + def shutdown_pool(pool) + pool.shutdown + pool.kill unless pool.wait_for_termination(1) + end +end