Add db table and models
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
module Push
|
||||
def self.table_name_prefix
|
||||
"push_"
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -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==
|
||||
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==
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Generated
+17
-1
@@ -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"
|
||||
|
||||
+145
-23
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user