thecore_backend_commons 3.2.11 → 3.3.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.
- checksums.yaml +4 -4
- data/README.md +127 -1
- data/app/channels/push_notification_channel.rb +18 -0
- data/app/models/push_message.rb +5 -0
- data/app/models/push_subscriber.rb +21 -0
- data/db/migrate/20260616000001_create_push_subscribers.rb +15 -0
- data/db/migrate/20260616000002_create_push_messages.rb +16 -0
- data/db/seeds.rb +12 -1
- data/lib/thecore_backend_commons/push_notification_service.rb +58 -0
- data/lib/thecore_backend_commons/version.rb +1 -1
- data/lib/thecore_backend_commons.rb +2 -0
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 00717650bd9fe40c41bdf1c30e6372a2abbd095f461b3d1271048e9e408cb8ea
|
|
4
|
+
data.tar.gz: b613ac4217fb65df2b934dc289b6d4167eb19d0f0605ab44914bb9996385467c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c6e95d2f7f3ad718c6a1722cc679be17761179f5dac5c0dbabe9590403f3c297a66f67223366871fe35a78d5786ad73699feb30a1028d76603310526fa20cccf
|
|
7
|
+
data.tar.gz: b7d31a43dbc06cb21846674ba6d3f74bed4d2061b7f70c21c823612a13bc5af14d350d0881bd21f9750b0417d53679008405ec9dac204c3c1134dc2e6b6ff041
|
data/README.md
CHANGED
|
@@ -1,4 +1,130 @@
|
|
|
1
|
-
This is part of Thecore framework
|
|
1
|
+
This is part of [Thecore framework](https://github.com/gabrieletassoni/thecore/tree/release/3).
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## Web Push notifications (VAPID)
|
|
6
|
+
|
|
7
|
+
Self-contained browser push notifications — no Firebase, no APNs, no third-party account required. The gem provides the server-side half: models, dispatch service, and ActionCable channel. The client integration guide (React + service worker) lives in the [`model_driven_api` README](../model_driven_api/README.md#web-push-vapid-from-a-react-client).
|
|
8
|
+
|
|
9
|
+
### How it works
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Browser Rails backend
|
|
13
|
+
│ │
|
|
14
|
+
│── POST subscribe ─────────►│ PushSubscriber.subscribe_for(user, endpoint:, p256dh:, auth:)
|
|
15
|
+
│ │
|
|
16
|
+
│◄─ ActionCable stream ──────│ PushNotificationChannel streams push_notifications_subscriber_N
|
|
17
|
+
│ │
|
|
18
|
+
│ [backend sends push] │ PushNotificationService.dispatch(subscriber, message)
|
|
19
|
+
│◄─ Web Push payload ────────│ → Webpush.payload_send (VAPID)
|
|
20
|
+
│ │ → message.sent_at = now
|
|
21
|
+
│── POST acknowledge ────────►│ message.update!(received_at: / read_at:)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Server configuration
|
|
25
|
+
|
|
26
|
+
VAPID keys are generated automatically the first time `rails db:seed` runs. You only need to set the contact email via RailsAdmin → Settings:
|
|
27
|
+
|
|
28
|
+
| ThecoreSettings key (`ns: :vapid`) | Purpose | Default |
|
|
29
|
+
|------------------------------------|---------|---------|
|
|
30
|
+
| `public_key` | VAPID public key (base64url) — send to browsers | generated at seed |
|
|
31
|
+
| `private_key` | VAPID private key — never expose to clients | generated at seed |
|
|
32
|
+
| `contact_email` | `mailto:` URI in VAPID `sub` claim | `""` (set this) |
|
|
33
|
+
| `max_messages_per_subscriber` | PushMessage retention cap per subscriber | `"500"` |
|
|
34
|
+
|
|
35
|
+
> **Regenerating VAPID keys invalidates all existing `PushSubscriber` records.** Every registered browser must re-subscribe.
|
|
36
|
+
|
|
37
|
+
### Models
|
|
38
|
+
|
|
39
|
+
#### `PushSubscriber`
|
|
40
|
+
|
|
41
|
+
Represents one browser/device subscription registered by a `User`.
|
|
42
|
+
|
|
43
|
+
| Column | Type | Notes |
|
|
44
|
+
|--------|------|-------|
|
|
45
|
+
| `user_id` | bigint | FK to users |
|
|
46
|
+
| `endpoint` | text | Unique; push service URL provided by the browser |
|
|
47
|
+
| `p256dh` | string | ECDH public key (base64url) |
|
|
48
|
+
| `auth` | string | Auth secret (base64url) |
|
|
49
|
+
| `user_agent` | string | Browser/OS identifier |
|
|
50
|
+
| `expired_at` | datetime | `nil` = active; set when the push service returns 410 |
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# Upsert by endpoint (re-registering an existing browser updates the record)
|
|
54
|
+
PushSubscriber.subscribe_for(user, endpoint:, p256dh:, auth:, user_agent:)
|
|
55
|
+
|
|
56
|
+
# Scope: only active (not expired)
|
|
57
|
+
PushSubscriber.active
|
|
58
|
+
|
|
59
|
+
# Expire a subscriber (called automatically on 410 from push service)
|
|
60
|
+
subscriber.expire!
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
#### `PushMessage`
|
|
64
|
+
|
|
65
|
+
Records a notification payload and its lifecycle.
|
|
66
|
+
|
|
67
|
+
| Column | Type | Notes |
|
|
68
|
+
|--------|------|-------|
|
|
69
|
+
| `push_subscriber_id` | bigint | FK |
|
|
70
|
+
| `title` | string | Required |
|
|
71
|
+
| `body` | text | Required |
|
|
72
|
+
| `url` | string | URL to open on notification click (optional) |
|
|
73
|
+
| `icon` | string | Notification icon URL (optional) |
|
|
74
|
+
| `sent_at` | datetime | Populated by `PushNotificationService` on successful dispatch |
|
|
75
|
+
| `received_at` | datetime | Set by client via `acknowledge` endpoint |
|
|
76
|
+
| `read_at` | datetime | Set by client via `acknowledge` endpoint |
|
|
77
|
+
|
|
78
|
+
Old messages beyond `vapid.max_messages_per_subscriber` are pruned automatically after each dispatch (oldest first).
|
|
79
|
+
|
|
80
|
+
### `PushNotificationService`
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
ThecoreBackendCommons::PushNotificationService.dispatch(subscriber, message)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
1. Calls `Webpush.payload_send` with the subscriber's keys and the VAPID credentials from `ThecoreSettings`.
|
|
87
|
+
2. On success: sets `message.sent_at = Time.current`.
|
|
88
|
+
3. On `Webpush::ExpiredSubscription` or `Webpush::InvalidSubscription` (HTTP 410/404 from push service): calls `subscriber.expire!`.
|
|
89
|
+
4. Prunes oldest `PushMessage` records if the subscriber exceeds the retention cap.
|
|
90
|
+
5. Always returns `message` — errors are rescued and logged, never propagated.
|
|
91
|
+
|
|
92
|
+
### `PushNotificationChannel` (ActionCable)
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# In your connection.rb — current_user must be set
|
|
96
|
+
class ApplicationCable::Connection < ActionCable::Connection::Base
|
|
97
|
+
identified_by :current_user
|
|
98
|
+
# ... authenticate and set current_user
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Subscribe from the frontend:
|
|
103
|
+
|
|
104
|
+
```javascript
|
|
105
|
+
// Stream for one subscriber (most common)
|
|
106
|
+
consumer.subscriptions.create(
|
|
107
|
+
{ channel: "PushNotificationChannel", subscriber_id: subscriberId },
|
|
108
|
+
{ received(data) { /* handle message */ } }
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// Stream for all active subscribers of the current user
|
|
112
|
+
consumer.subscriptions.create(
|
|
113
|
+
{ channel: "PushNotificationChannel", user_id: currentUserId },
|
|
114
|
+
{ received(data) { /* handle message */ } }
|
|
115
|
+
);
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The channel only streams to `subscriber_id` values that belong to `current_user` — unauthorized subscriber IDs are silently ignored.
|
|
119
|
+
|
|
120
|
+
Broadcast from anywhere in the backend:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
PushNotificationChannel.broadcast_to(subscriber, message)
|
|
124
|
+
# Sends message.as_json to "push_notifications_subscriber_#{subscriber.id}"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
2
128
|
|
|
3
129
|
## Invio email e configurazione SMTP
|
|
4
130
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class PushNotificationChannel < ApplicationCable::Channel
|
|
2
|
+
def subscribed
|
|
3
|
+
if params[:subscriber_id].present?
|
|
4
|
+
subscriber = PushSubscriber.active.find_by(id: params[:subscriber_id], user_id: current_user.id)
|
|
5
|
+
stream_from "push_notifications_subscriber_#{subscriber.id}" if subscriber
|
|
6
|
+
elsif params[:user_id].present? && params[:user_id].to_i == current_user.id
|
|
7
|
+
PushSubscriber.active.where(user_id: current_user.id).each do |sub|
|
|
8
|
+
stream_from "push_notifications_subscriber_#{sub.id}"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def unsubscribed; end
|
|
14
|
+
|
|
15
|
+
def self.broadcast_to(subscriber, message)
|
|
16
|
+
ActionCable.server.broadcast("push_notifications_subscriber_#{subscriber.id}", message.as_json)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class PushSubscriber < ApplicationRecord
|
|
2
|
+
belongs_to :user
|
|
3
|
+
has_many :push_messages, dependent: :destroy
|
|
4
|
+
validates :endpoint, presence: true, uniqueness: true
|
|
5
|
+
scope :active, -> { where(expired_at: nil) }
|
|
6
|
+
|
|
7
|
+
def expire!
|
|
8
|
+
update!(expired_at: Time.current)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.subscribe_for(user, endpoint:, p256dh: nil, auth: nil, user_agent: nil)
|
|
12
|
+
record = find_or_initialize_by(endpoint: endpoint)
|
|
13
|
+
record.user = user
|
|
14
|
+
record.p256dh = p256dh
|
|
15
|
+
record.auth = auth
|
|
16
|
+
record.user_agent = user_agent
|
|
17
|
+
record.expired_at = nil
|
|
18
|
+
record.save!
|
|
19
|
+
record
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreatePushSubscribers < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
create_table :push_subscribers do |t|
|
|
4
|
+
t.bigint :user_id, null: false
|
|
5
|
+
t.text :endpoint, null: false
|
|
6
|
+
t.string :p256dh
|
|
7
|
+
t.string :auth
|
|
8
|
+
t.string :user_agent
|
|
9
|
+
t.datetime :expired_at
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
add_index :push_subscribers, :endpoint, unique: true
|
|
13
|
+
add_index :push_subscribers, :user_id
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreatePushMessages < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
create_table :push_messages do |t|
|
|
4
|
+
t.bigint :push_subscriber_id, null: false
|
|
5
|
+
t.string :title, null: false
|
|
6
|
+
t.text :body, null: false
|
|
7
|
+
t.string :url
|
|
8
|
+
t.string :icon
|
|
9
|
+
t.datetime :sent_at
|
|
10
|
+
t.datetime :received_at
|
|
11
|
+
t.datetime :read_at
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
add_index :push_messages, :push_subscriber_id
|
|
15
|
+
end
|
|
16
|
+
end
|
data/db/seeds.rb
CHANGED
|
@@ -10,4 +10,15 @@ Thecore::Seed.save_setting :smtp, :domain, ""
|
|
|
10
10
|
Thecore::Seed.save_setting :smtp, :user_name, ""
|
|
11
11
|
Thecore::Seed.save_setting :smtp, :password, ""
|
|
12
12
|
Thecore::Seed.save_setting :smtp, :authentication, ""
|
|
13
|
-
Thecore::Seed.save_setting :smtp, :enable_starttls_auto, ""
|
|
13
|
+
Thecore::Seed.save_setting :smtp, :enable_starttls_auto, ""
|
|
14
|
+
|
|
15
|
+
puts "Loading ThecoreBackendCommons VAPID config"
|
|
16
|
+
require "web-push"
|
|
17
|
+
unless ThecoreSettings::Setting.where(ns: :vapid, key: :public_key).where.not(raw: [nil, ""]).exists?
|
|
18
|
+
vapid_key = WebPush.generate_key
|
|
19
|
+
Thecore::Seed.save_setting :vapid, :public_key, vapid_key.public_key
|
|
20
|
+
Thecore::Seed.save_setting :vapid, :private_key, vapid_key.private_key
|
|
21
|
+
puts " Generated new VAPID key pair"
|
|
22
|
+
end
|
|
23
|
+
Thecore::Seed.save_setting :vapid, :contact_email, "" unless ThecoreSettings::Setting.where(ns: :vapid, key: :contact_email).exists?
|
|
24
|
+
Thecore::Seed.save_setting :vapid, :max_messages_per_subscriber, "500" unless ThecoreSettings::Setting.where(ns: :vapid, key: :max_messages_per_subscriber).exists?
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module ThecoreBackendCommons
|
|
2
|
+
class PushNotificationService
|
|
3
|
+
MAX_MESSAGES_DEFAULT = 500
|
|
4
|
+
|
|
5
|
+
def self.dispatch(subscriber, message)
|
|
6
|
+
new(subscriber, message).dispatch
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(subscriber, message)
|
|
10
|
+
@subscriber = subscriber
|
|
11
|
+
@message = message
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def dispatch
|
|
15
|
+
send_push
|
|
16
|
+
prune_old_messages
|
|
17
|
+
@message
|
|
18
|
+
rescue => e
|
|
19
|
+
Rails.logger.error("[PushNotificationService] dispatch failed: #{e.message}")
|
|
20
|
+
@message
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def send_push
|
|
26
|
+
WebPush.payload_send(
|
|
27
|
+
message: JSON.generate(payload),
|
|
28
|
+
endpoint: @subscriber.endpoint,
|
|
29
|
+
p256dh: @subscriber.p256dh,
|
|
30
|
+
auth: @subscriber.auth,
|
|
31
|
+
vapid: vapid_options
|
|
32
|
+
)
|
|
33
|
+
@message.update!(sent_at: Time.current)
|
|
34
|
+
rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription
|
|
35
|
+
@subscriber.expire!
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def payload
|
|
39
|
+
{ title: @message.title, body: @message.body, url: @message.url, icon: @message.icon }.compact
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def vapid_options
|
|
43
|
+
{
|
|
44
|
+
public_key: ThecoreSettings::Setting.where(ns: :vapid, key: :public_key).pluck(:raw).first,
|
|
45
|
+
private_key: ThecoreSettings::Setting.where(ns: :vapid, key: :private_key).pluck(:raw).first,
|
|
46
|
+
subject: "mailto:#{ThecoreSettings::Setting.where(ns: :vapid, key: :contact_email).pluck(:raw).first.presence || 'admin@example.com'}"
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def prune_old_messages
|
|
51
|
+
limit = ThecoreSettings::Setting.where(ns: :vapid, key: :max_messages_per_subscriber).pluck(:raw).first&.to_i || MAX_MESSAGES_DEFAULT
|
|
52
|
+
count = @subscriber.push_messages.count
|
|
53
|
+
return unless count > limit
|
|
54
|
+
oldest_ids = @subscriber.push_messages.order(created_at: :asc).limit(count - limit).pluck(:id)
|
|
55
|
+
PushMessage.where(id: oldest_ids).delete_all
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "ostruct"
|
|
2
|
+
require "web-push" # gem for VAPID web push (pushpad/web-push, actively maintained)
|
|
2
3
|
require "thecore_auth_commons"
|
|
3
4
|
require "thecore_background_jobs"
|
|
4
5
|
require "rails-i18n"
|
|
@@ -15,6 +16,7 @@ require "thecore_backend_commons/version"
|
|
|
15
16
|
require "thecore_backend_commons/engine"
|
|
16
17
|
require "thecore_backend_commons/smtp_config"
|
|
17
18
|
require "thecore_backend_commons/smtp_tester"
|
|
19
|
+
require "thecore_backend_commons/push_notification_service"
|
|
18
20
|
|
|
19
21
|
module ThecoreBackendCommons
|
|
20
22
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: thecore_backend_commons
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gabriele Tassoni
|
|
@@ -177,6 +177,20 @@ dependencies:
|
|
|
177
177
|
- - "~>"
|
|
178
178
|
- !ruby/object:Gem::Version
|
|
179
179
|
version: '3.4'
|
|
180
|
+
- !ruby/object:Gem::Dependency
|
|
181
|
+
name: web-push
|
|
182
|
+
requirement: !ruby/object:Gem::Requirement
|
|
183
|
+
requirements:
|
|
184
|
+
- - "~>"
|
|
185
|
+
- !ruby/object:Gem::Version
|
|
186
|
+
version: '3.0'
|
|
187
|
+
type: :runtime
|
|
188
|
+
prerelease: false
|
|
189
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
190
|
+
requirements:
|
|
191
|
+
- - "~>"
|
|
192
|
+
- !ruby/object:Gem::Version
|
|
193
|
+
version: '3.0'
|
|
180
194
|
description: Wrapper to keep all the common libraries and setups needed by Thecore
|
|
181
195
|
UI Backend(s).
|
|
182
196
|
email:
|
|
@@ -188,7 +202,10 @@ files:
|
|
|
188
202
|
- README.md
|
|
189
203
|
- Rakefile
|
|
190
204
|
- app/channels/activity_log_channel.rb
|
|
205
|
+
- app/channels/push_notification_channel.rb
|
|
191
206
|
- app/mailers/concerns/smtp_deliverable.rb
|
|
207
|
+
- app/models/push_message.rb
|
|
208
|
+
- app/models/push_subscriber.rb
|
|
192
209
|
- config/initializers/abilities.rb
|
|
193
210
|
- config/initializers/add_to_db_migrations.rb
|
|
194
211
|
- config/initializers/after_initialize.rb
|
|
@@ -203,10 +220,13 @@ files:
|
|
|
203
220
|
- config/locales/en.yml
|
|
204
221
|
- config/locales/it.devise.custom.yml
|
|
205
222
|
- config/locales/it.yml
|
|
223
|
+
- db/migrate/20260616000001_create_push_subscribers.rb
|
|
224
|
+
- db/migrate/20260616000002_create_push_messages.rb
|
|
206
225
|
- db/seeds.rb
|
|
207
226
|
- lib/tasks/thecore_backend_commons_tasks.rake
|
|
208
227
|
- lib/thecore_backend_commons.rb
|
|
209
228
|
- lib/thecore_backend_commons/engine.rb
|
|
229
|
+
- lib/thecore_backend_commons/push_notification_service.rb
|
|
210
230
|
- lib/thecore_backend_commons/smtp_config.rb
|
|
211
231
|
- lib/thecore_backend_commons/smtp_tester.rb
|
|
212
232
|
- lib/thecore_backend_commons/version.rb
|