Diff -- /var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/MODS/PTMatrix/WebPush.pm
Diff

/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/MODS/PTMatrix/WebPush.pm

added on local at 2026-07-01 12:34:29

Added
+212
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to a80565e72831
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package MODS::PTMatrix::WebPush;
2#======================================================================
3# PTMatrix -- Web Push sender (RFC 8030 + 8292 VAPID).
4#
5# Sends EMPTY-payload pushes. The service worker (/sw.js) then fetches
6# /notifications.cgi?act=latest_unread to decide what to show. This lets
7# us skip RFC 8291 payload encryption entirely — the only crypto we need
8# is an ES256 JWT signature for VAPID, which we do via shell-out to
9# `openssl dgst -sha256 -sign` and a tiny DER->JOSE converter.
10#
11# Public:
12# send_for_user($db, $dbh, $DB, $user_id) -> ($sent, $deleted)
13#======================================================================
14use strict;
15use warnings;
16use Digest::SHA qw(sha256);
17use MIME::Base64 qw(encode_base64);
18use IPC::Open3;
19use Symbol 'gensym';
20
21sub new { return bless { _have_openssl => undef }, shift }
22
23# base64url without padding
24sub _b64url {
25 my $bytes = shift // '';
26 my $b = encode_base64($bytes, '');
27 $b =~ tr|+/|-_|;
28 $b =~ s/=+$//;
29 return $b;
30}
31
32sub _have_openssl {
33 my $self = shift;
34 return $self->{_have_openssl} if defined $self->{_have_openssl};
35 my $ok = (-x '/usr/bin/openssl') ? 1 : 0;
36 $self->{_have_openssl} = $ok;
37 return $ok;
38}
39
40# Convert ECDSA DER signature (SEQUENCE { INTEGER r, INTEGER s }) into raw
41# r||s (64 bytes for P-256) for JWS / JOSE.
42sub _der_to_jose {
43 my ($der) = @_;
44 return undef unless defined $der && length($der) > 8;
45 my $i = 0;
46 return undef unless substr($der, $i++, 1) eq "\x30";
47 # length byte (may be long-form, but for P-256 sigs it's always 1 byte)
48 my $L = ord(substr($der, $i++, 1));
49 if ($L & 0x80) { $i += ($L & 0x7F); } # skip long-form length octets
50 # INTEGER r
51 return undef unless substr($der, $i++, 1) eq "\x02";
52 my $rL = ord(substr($der, $i++, 1));
53 my $r = substr($der, $i, $rL); $i += $rL;
54 # INTEGER s
55 return undef unless substr($der, $i++, 1) eq "\x02";
56 my $sL = ord(substr($der, $i++, 1));
57 my $s = substr($der, $i, $sL);
58 # Strip a leading 0x00 sign byte if present, pad/truncate to 32 bytes each
59 $r =~ s/^\x00// while length($r) > 32 && substr($r, 0, 1) eq "\x00";
60 $s =~ s/^\x00// while length($s) > 32 && substr($s, 0, 1) eq "\x00";
61 $r = ("\x00" x (32 - length($r))) . $r if length($r) < 32;
62 $s = ("\x00" x (32 - length($s))) . $s if length($s) < 32;
63 return $r . $s;
64}
65
66# Sign a string with the VAPID private key (PEM at $key_path) using
67# ECDSA-P256-SHA256. Returns raw 64-byte JOSE signature, or undef.
68sub _sign_es256 {
69 my ($self, $key_path, $msg) = @_;
70 return undef unless $self->_have_openssl;
71 my $err = gensym;
72 my $pid = open3(my $in, my $out, $err,
73 '/usr/bin/openssl', 'dgst', '-sha256', '-sign', $key_path);
74 binmode $in; binmode $out;
75 print $in $msg;
76 close $in;
77 local $/;
78 my $der = <$out>;
79 my $err_text = <$err>;
80 waitpid $pid, 0;
81 return undef unless defined $der && length $der;
82 return _der_to_jose($der);
83}
84
85# Build a VAPID JWT for a given audience (push-service origin).
86sub _vapid_jwt {
87 my ($self, $key_path, $aud, $subject, $exp_secs) = @_;
88 $exp_secs ||= time + 12 * 3600;
89 my $header = '{"typ":"JWT","alg":"ES256"}';
90 my $payload = sprintf('{"aud":"%s","exp":%d,"sub":"%s"}',
91 _json_esc($aud), $exp_secs, _json_esc($subject));
92 my $signing_input = _b64url($header) . '.' . _b64url($payload);
93 my $sig = $self->_sign_es256($key_path, $signing_input);
94 return undef unless defined $sig && length $sig == 64;
95 return $signing_input . '.' . _b64url($sig);
96}
97sub _json_esc {
98 my $s = shift // '';
99 $s =~ s/\\/\\\\/g; $s =~ s/"/\\"/g; $s =~ s/\n/\\n/g; $s =~ s/\r/\\r/g;
100 return $s;
101}
102
103# Pull the configured VAPID public key from platform_settings.
104sub _vapid_keys {
105 my ($self, $db, $dbh, $DB) = @_;
106 my $pub = $db->db_readwrite($dbh, qq~
107 SELECT setting_value FROM ${DB}.platform_settings
108 WHERE setting_key='vapid_public_key' LIMIT 1
109 ~, 'WebPush', __LINE__);
110 my $sub = $db->db_readwrite($dbh, qq~
111 SELECT setting_value FROM ${DB}.platform_settings
112 WHERE setting_key='vapid_subject' LIMIT 1
113 ~, 'WebPush', __LINE__);
114 return (
115 ($pub && $pub->{setting_value}) || '',
116 ($sub && $sub->{setting_value}) || 'mailto:support@ptmatrix.3dshawn.com',
117 );
118}
119
120# Extract scheme://host from any URL for VAPID `aud`.
121sub _audience {
122 my $url = shift // '';
123 return $1 if $url =~ m{^(https?://[^/]+)};
124 return '';
125}
126
127# Send one empty-payload push to a single subscription. Returns HTTP status.
128sub _send_one {
129 my ($self, $sub, $pub_b64, $subject, $key_path) = @_;
130 my $aud = _audience($sub->{endpoint});
131 return 0 unless $aud;
132 my $jwt = $self->_vapid_jwt($key_path, $aud, $subject);
133 return 0 unless $jwt;
134
135 # Headers per RFC 8292 / 8030:
136 # Authorization: vapid t=<jwt>, k=<public-key-b64url>
137 # TTL: 60 (push service may keep undelivered for 60s)
138 # Content-Length: 0
139 # Urgency: normal
140 my $auth_hdr = "vapid t=$jwt, k=$pub_b64";
141
142 my $err = gensym;
143 my @cmd = (
144 '/usr/bin/curl', '-sS', '-o', '/dev/null',
145 '-w', '%{http_code}',
146 '-X', 'POST',
147 '--max-time', '10',
148 '-H', "Authorization: $auth_hdr",
149 '-H', 'TTL: 60',
150 '-H', 'Content-Length: 0',
151 '-H', 'Urgency: normal',
152 $sub->{endpoint},
153 );
154 my $pid = open3(my $in, my $out, $err, @cmd);
155 close $in;
156 local $/;
157 my $code = <$out> || '';
158 waitpid $pid, 0;
159 $code =~ s/\D//g;
160 return $code + 0;
161}
162
163# Fan-out to every active subscription for a user. Returns (sent, deleted).
164# A 404 or 410 response from the push service means the subscription is dead
165# and we soft-delete it. 401/403 means our VAPID signing is broken — we don't
166# delete in that case (would be a deploy bug).
167sub send_for_user {
168 my ($self, $db, $dbh, $DB, $user_id) = @_;
169 return (0, 0) unless $user_id;
170 return (0, 0) unless $self->_have_openssl;
171
172 my ($pub, $subj) = $self->_vapid_keys($db, $dbh, $DB);
173 return (0, 0) unless length $pub;
174
175 my $key_path = '/var/www/vhosts/3dshawn.com/ptmatrix.3dshawn.com/_vapid/private.pem';
176 return (0, 0) unless -r $key_path;
177
178 my @subs = $db->db_readwrite_multiple($dbh, qq~
179 SELECT id, endpoint, p256dh_key, auth_key FROM ${DB}.push_subscriptions
180 WHERE user_id='$user_id' AND deleted_at IS NULL
181 LIMIT 25
182 ~, 'WebPush', __LINE__);
183
184 my ($sent, $deleted) = (0, 0);
185 foreach my $sub (@subs) {
186 my $code = $self->_send_one($sub, $pub, $subj, $key_path);
187 if ($code == 201 || $code == 202 || $code == 200) {
188 $sent++;
189 $db->db_readwrite($dbh, qq~
190 UPDATE ${DB}.push_subscriptions SET last_used_at=NOW(),
191 last_error_at=NULL, last_error_code=NULL
192 WHERE id='$sub->{id}'
193 ~, 'WebPush', __LINE__);
194 } elsif ($code == 404 || $code == 410) {
195 $deleted++;
196 $db->db_readwrite($dbh, qq~
197 UPDATE ${DB}.push_subscriptions SET deleted_at=NOW(),
198 last_error_at=NOW(), last_error_code='$code'
199 WHERE id='$sub->{id}'
200 ~, 'WebPush', __LINE__);
201 } else {
202 $db->db_readwrite($dbh, qq~
203 UPDATE ${DB}.push_subscriptions SET last_error_at=NOW(),
204 last_error_code='$code'
205 WHERE id='$sub->{id}'
206 ~, 'WebPush', __LINE__);
207 }
208 }
209 return ($sent, $deleted);
210}
211
2121;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help