• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

Freegle / iznik-server / #2461

27 Nov 2025 11:06AM UTC coverage: 90.577%. Remained the same
#2461

push

php-coveralls

edwh
Revert "Implement per-message push notifications with channel_id for Android"

This reverts commit 57a53c493.

6 of 6 new or added lines in 2 files covered. (100.0%)

29 existing lines in 1 file now uncovered.

26347 of 29088 relevant lines covered (90.58%)

31.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.93
/include/user/PushNotifications.php
1
<?php
2
namespace Freegle\Iznik;
3

4
use Minishlink\WebPush\WebPush;
5
use Minishlink\WebPush\Subscription;
6
use Pheanstalk\Pheanstalk;
7
use Kreait\Firebase\Factory;
8
use Kreait\Firebase\ServiceAccount;
9
use Kreait\Firebase\Messaging\CloudMessage;
10
use Kreait\Firebase\Messaging\AndroidConfig;
11
use Kreait\Firebase\Messaging\ApnsConfig;
12
use Kreait\Firebase\Exception\Messaging\InvalidMessage;
13

14
class PushNotifications
15
{
16
    const PUSH_GOOGLE = 'Google'; // Obsolete
17
    const PUSH_FIREFOX = 'Firefox'; // Obsolete
18
    const PUSH_TEST = 'Test';
19
    const PUSH_FCM_ANDROID = 'FCMAndroid';
20
    const PUSH_FCM_IOS = 'FCMIOS';
21
    const APPTYPE_MODTOOLS = 'ModTools';
22
    const APPTYPE_USER = 'User';
23
    const PUSH_BROWSER_PUSH = 'BrowserPush';
24

25
    private $dbhr, $dbhm, $log, $pheanstalk = NULL, $firebase = NULL, $messaging = NULL;
26

27
    function __construct(LoggedPDO $dbhr, LoggedPDO $dbhm)
28
    {
29
        $this->dbhr = $dbhr;
639✔
30
        $this->dbhm = $dbhm;
639✔
31
        $this->log = new Log($dbhr, $dbhm);
639✔
32

33
        if (file_exists('/etc/firebase.json')) {
639✔
UNCOV
34
            $factory = (new Factory)
×
UNCOV
35
                ->withServiceAccount('/etc/firebase.json');
×
UNCOV
36
            $this->messaging = $factory->createMessaging();
×
37
        }
38
    }
39

40
    public function get($userid)
41
    {
42
        # Cache the notification - saves a DB call in GET of session, which is very common.
43
        $ret = Utils::presdef('notification', $_SESSION, []);
21✔
44

45
        if (!$ret) {
21✔
46
            $sql = "SELECT * FROM users_push_notifications WHERE userid = ?;";
21✔
47
            $notifs = $this->dbhr->preQuery($sql, [$userid]);
21✔
48
            foreach ($notifs as &$notif) {
21✔
49
                $notif['added'] = Utils::ISODate($notif['added']);
3✔
50
                $ret[] = $notif;
3✔
51
            }
52

53
            $_SESSION['notification'] = $ret;
21✔
54
        }
55

56
        return ($ret);
21✔
57
    }
58

59
    public function add($userid, $type, $val, $modtools = NULL)
60
    {
61
        if (is_null($modtools)) {
3✔
62
            $modtools = Session::modtools();
3✔
63
        }
64

65
        $rc = NULL;
3✔
66

67
        if ($userid) {
3✔
68
            $apptype = $modtools ? PushNotifications::APPTYPE_MODTOOLS : PushNotifications::APPTYPE_USER;
3✔
69
            $sql = "INSERT INTO users_push_notifications (`userid`, `type`, `subscription`, `apptype`) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE userid = ?, type = ?, apptype = ?;";
3✔
70
            $rc = $this->dbhm->preExec($sql, [$userid, $type, $val, $apptype, $userid, $type, $apptype]);
3✔
71
            Session::clearSessionCache();
3✔
72
        }
73

74
        return ($rc);
3✔
75
    }
76

77
    public function remove($userid)
78
    {
79
        $sql = "DELETE FROM users_push_notifications WHERE userid = ?;";
2✔
80
        $rc = $this->dbhm->preExec($sql, [$userid]);
2✔
81
        return ($rc);
2✔
82
    }
83

84
    public function uthook($rc = NULL)
85
    {
86
        # Mocked in UT to force an exception.
87
        return ($rc);
123✔
88
    }
89

90
    private function queueSend($userid, $type, $params, $endpoint, $payload)
91
    {
92
        #error_log("queueSend $userid $endpoint params " . var_export($params, TRUE));
93
        try {
94
            $this->uthook();
2✔
95

96
            if (!$this->pheanstalk) {
2✔
97
                $this->pheanstalk = Pheanstalk::create(PHEANSTALK_SERVER);
2✔
98
            }
99

100
            $str = json_encode(array(
2✔
101
                'type' => 'webpush',
2✔
102
                'notiftype' => $type,
2✔
103
                'queued' => microtime(TRUE),
2✔
104
                'userid' => $userid,
2✔
105
                'params' => $params,
2✔
106
                'endpoint' => $endpoint,
2✔
107
                'payload' => $payload,
2✔
108
                'ttr' => Utils::PHEANSTALK_TTR
2✔
109
            ));
2✔
110

111
            $id = $this->pheanstalk->put($str);
2✔
112
        } catch (\Exception $e) {
2✔
113
            error_log("Beanstalk exception " . $e->getMessage());
2✔
114
            $this->pheanstalk = NULL;
2✔
115
        }
116
    }
117

118
    public function executeSend($userid, $notiftype, $params, $endpoint, $payload)
119
    {
120
        #error_log("Execute send type $notiftype params " . var_export($params, TRUE) . " payload " . var_export($payload, TRUE) . " endpoint $endpoint");
121
        try {
122
            error_log("notiftype " . $notiftype . " userid " . $userid);
2✔
123

124
            switch ($notiftype) {
125
                case PushNotifications::PUSH_FCM_ANDROID:
126
                case PushNotifications::PUSH_FCM_IOS:
127
                case PushNotifications::PUSH_BROWSER_PUSH:
128
                    {
1✔
129
                        # Everything is in one array as passed to this function; split it out into what we need
130
                        # for FCM.
131
                        #error_log("FCM notif " . var_export($payload, TRUE));
132
                        $data = $payload;
1✔
133

134
                        # We can only have key => string, so the chatids needs to be converted from an array to
135
                        # a string.
136
                        $data['chatids'] = implode(',', $data['chatids']);
1✔
137

138
                        # And anything that isn't a string needs to pretend to be one.  How dull.
139
                        foreach ($data as $key => $val) {
1✔
140
                            if (gettype($val) !== 'string') {
1✔
141
                                $data[$key] = "$val";
1✔
142
                            }
1✔
143
                        }
1✔
144

145
                        $data['notId'] = (string)floor(microtime(TRUE));
1✔
146

147
                        #error_log("Data is " . var_export($data, TRUE));
148

149
                        if ($notiftype == PushNotifications::PUSH_FCM_ANDROID) {
1✔
150
                            # Need to omit notification for reasons to do with Cordova plugin.
151
                            if ($payload['count']) {
1✔
152
                                $data['content-available'] = "1";
1✔
153
                            }
1✔
154

155
                            $message = CloudMessage::fromArray([
1✔
156
                                'token' => $endpoint,
1✔
157
                                'data' => $data
1✔
158
                            ]);
1✔
159

160
                            $message = $message->withAndroidConfig([
1✔
161
                                'ttl' => '3600s',
1✔
162
                                'priority' => 'normal'
1✔
163
                            ]);
1✔
164
                        } else {
1✔
165
                            # For IOS and browser push notifications.
166
                            $ios = [
1✔
167
                                'token' => $endpoint,
1✔
168
                                'data' => $data
1✔
169
                            ];
1✔
170

171
                            if (!empty($payload['title'])) {   // Don't set notification if clearing
1✔
172
                                $iostitle = $payload['title'];
1✔
173
                                $iosbody = $payload['message'];
1✔
174

175
                                if (empty($iosbody)) {  // older iOS only shows body and doesn't show if body empty
1✔
176
                                    $iosbody = $iostitle;
1✔
177
                                    $iostitle = ' ';
1✔
178
                                }
1✔
179

180
                                $ios['notification'] = [
1✔
181
                                    'title' => $iostitle,
1✔
182
                                    'body' => $iosbody,
1✔
183
                                ];
1✔
184

185
                                if ($notiftype == PushNotifications::PUSH_BROWSER_PUSH) {
1✔
186
                                    $ios['notification']['webpush'] = [
1✔
187
                                        'fcm_options' => [
1✔
188
                                            'link' => $payload['route']
1✔
189
                                        ]
1✔
190
                                    ];
1✔
191
                                }
1✔
192
                            }
1✔
193

194
                            #error_log("ios is " . var_export($ios, TRUE));
195
                            $message = CloudMessage::fromArray($ios);
1✔
196
                            $params = [
1✔
197
                                'headers' => [
1✔
198
                                    'apns-priority' => '10',
1✔
199
                                ],
1✔
200
                                'payload' => [
1✔
201
                                    'aps' => [
1✔
202
                                        'badge' => $payload['count'],
1✔
203
                                        'sound' => "default"
1✔
204
                                        //'content-available' => 1
1✔
205
                                    ]
1✔
206
                                ],
1✔
207
                            ];
1✔
208

209
                            #error_log("Send params " . var_export($params, TRUE));
210
                            #error_log("Send payload " . var_export($ios, TRUE));
211
                            $message = $message->withApnsConfig($params);
1✔
212
                        }
1✔
213

214
                        try {
1✔
215
                            if ($this->messaging) {
1✔
216
                                $this->messaging->validate($message);
1✔
217
                            }
1✔
UNCOV
218
                        } catch (InvalidMessage $e) {
×
219
                            # We might not want to remove the subscription.  Check the nature of the error
220
                            # and (for now) record unknown ones to check.
221
                            $error = $e->errors()['error'];
1✔
222
                            file_put_contents('/tmp/fcmerrors', date(DATE_RFC2822) . ': ' . $userid . ' - ' . $endpoint . ' - ' . var_export($error, TRUE) . "\r\n", FILE_APPEND);
1✔
223
                            error_log("FCM InvalidMessage " . var_export($error, TRUE));
1✔
224
                            $errorCode = 'CODE NOT FOUND';
1✔
225
                            if (array_key_exists('errorCode', $error['details'][0])) {
×
226
                                $errorCode = $error['details'][0]['errorCode'];
1✔
227
                            }
1✔
228
                            error_log("FCM errorCode " . $errorCode);
1✔
229

UNCOV
230
                            if ($errorCode == 'UNREGISTERED') {
×
231
                                # We do want to remove the subscription in this case.
UNCOV
232
                                throw new \Exception($errorCode);
×
233
                            }
1✔
234

UNCOV
235
                            foreach ($error['details'] as $detail) {
×
UNCOV
236
                                if (array_key_exists('fieldViolations', $detail)) {
×
UNCOV
237
                                    if ($detail['fieldViolations'][0]['description'] == 'Invalid registration token') {
×
238
                                        # We do want to remove the subscription in this case.
UNCOV
239
                                        throw new \Exception($detail['fieldViolations'][0]['description']);
×
240
                                    }
1✔
UNCOV
241
                                    if ($detail['fieldViolations'][0]['description'] == 'The registration token is not a valid FCM registration token') {
×
242
                                        # We do want to remove the subscription in this case.
UNCOV
243
                                        throw new \Exception($detail['fieldViolations'][0]['description']);
×
244
                                    }
1✔
245
                                }
1✔
246
                            }
1✔
247

248
                            $rc = TRUE; // Problem is ignored and subscription/token NOT removed: eyeball logs to check
1✔
249
                            break;
1✔
250
                        }
1✔
251

252
                        if ($this->messaging) {
1✔
253
                            $ret = $this->messaging->send($message);
1✔
254
                            error_log("FCM send " . var_export($ret, TRUE));
1✔
255
                        }
1✔
256

257
                        $rc = TRUE;
1✔
258
                        break;
1✔
259
                    }
1✔
260
                case PushNotifications::PUSH_GOOGLE:
261
                case PushNotifications::PUSH_FIREFOX:
262
                    $params = $params ? $params : [];
2✔
263
                    $webPush = new WebPush($params);
2✔
264
                    $subscription = Subscription::create([
2✔
265
                                                           "endpoint" => $endpoint,
2✔
266
                                                       ]);
2✔
267
                    #error_log("Send params " . var_export($params, TRUE) . " " . ($payload['count'] > 0) . "," . (!is_null($payload['title'])));
268
                    if (($payload && ($payload['count'] > 0) && (!is_null($payload['title'])))) {
2✔
269
                        $rc = $webPush->queueNotification($subscription, $payload['title']);
1✔
270
                    } else
271
                        $rc = TRUE;
2✔
272
                    break;
2✔
273
            }
274

275
            #error_log("Returned " . var_export($rc, TRUE) . " for $userid type $notiftype $endpoint payload " . var_export($payload, TRUE));
276
            $rc = $this->uthook($rc);
2✔
277
        } catch (\Exception $e) {
2✔
278
            $rc = ['exception' => $e->getMessage()];
2✔
279
            #error_log("push exc " . var_export($e, TRUE));
280
            #error_log("push exc " . $e->getMessage());
281
            error_log("Push exception {$rc['exception']}");
2✔
282
        }
283

284
        if ($rc !== TRUE) {
2✔
285
            error_log("Push Notification to $userid failed with " . var_export($rc, TRUE));
2✔
286
            $this->dbhm->preExec("DELETE FROM users_push_notifications WHERE userid = ? AND subscription = ?;", [$userid, $endpoint]);
2✔
287
        } else {
288
            # Don't log - lots of these.
289
            $this->dbhm->preExec("UPDATE users_push_notifications SET lastsent = NOW() WHERE userid = ? AND subscription = ?;", [$userid, $endpoint], FALSE);
2✔
290
        }
291

292
        return $rc;
2✔
293
    }
294

295
    public function notify($userid, $modtools, $browserPush = FALSE)
296
    {
297
        $count = 0;
596✔
298
        $u = User::get($this->dbhr, $this->dbhm, $userid);
596✔
299
        $proceedpush = TRUE; // $u->notifsOn(User::NOTIFS_PUSH);
596✔
300
        $proceedapp = $u->notifsOn(User::NOTIFS_APP);
596✔
301
        #error_log("Notify $userid, push on $proceedpush app on $proceedapp MT $modtools browserPush $browserPush");
302

303
        if ($browserPush) {
596✔
UNCOV
304
            $notifs = $this->dbhr->preQuery("SELECT * FROM users_push_notifications WHERE userid = ? AND `type` = ?;", [
×
UNCOV
305
                $userid,
×
UNCOV
306
                PushNotifications::PUSH_BROWSER_PUSH
×
UNCOV
307
            ]);
×
308
        } else {
309
            $notifs = $this->dbhr->preQuery("SELECT * FROM users_push_notifications WHERE userid = ? AND apptype = ?;", [
596✔
310
                $userid,
596✔
311
                $modtools ? PushNotifications::APPTYPE_MODTOOLS : PushNotifications::APPTYPE_USER
596✔
312
            ]);
596✔
313
        }
314

315
        foreach ($notifs as $notif) {
596✔
316
            #error_log("Consider notif {$notif['id']} proceed $proceedpush type {$notif['type']}");
317
            if ($proceedpush && in_array($notif['type'],
2✔
318
                    [PushNotifications::PUSH_FIREFOX, PushNotifications::PUSH_GOOGLE, PushNotifications::PUSH_BROWSER_PUSH]) ||
2✔
319
                ($proceedapp && in_array($notif['type'],
2✔
320
                        [PushNotifications::PUSH_FCM_ANDROID, PushNotifications::PUSH_FCM_IOS]))) {
2✔
321
                #error_log("Send user $userid {$notif['subscription']} type {$notif['type']} for modtools $modtools");
322
                $payload = NULL;
2✔
323
                $params = [];
2✔
324

325
                list ($total, $chatcount, $notifscount, $title, $message, $chatids, $route) = $u->getNotificationPayload($modtools);
2✔
326

327
                if ($title || $modtools || $total === 0) {
2✔
328
                    $message = ($total === 0) ? "" : $message;
2✔
329
                    if (is_null($message)) $message = "";
2✔
330

331
                    # badge and/or count are used by the app, possibly when it isn't running, to set the home screen badge.
332
                    $payload = [
2✔
333
                        'badge' => $total,
2✔
334
                        'count' => $total,
2✔
335
                        'chatcount' => $chatcount,
2✔
336
                        'notifcount' => $notifscount,
2✔
337
                        'title' => $title,
2✔
338
                        'message' => $message,
2✔
339
                        'chatids' => $chatids,
2✔
340
                        'content-available' => $total > 0,
2✔
341
                        'image' => $modtools ? "www/images/modtools_logo.png" : "www/images/user_logo.png",
2✔
342
                        'modtools' => $modtools,
2✔
343
                        'sound' => 'default',
2✔
344
                        'route' => $route
2✔
345
                    ];
2✔
346

347
                    switch ($notif['type']) {
2✔
348
                        case PushNotifications::PUSH_GOOGLE:
349
                        {
1✔
350
                            $params = [
1✔
351
                                'GCM' => GOOGLE_PUSH_KEY
1✔
352
                            ];
1✔
353
                            break;
1✔
354
                        }
1✔
355
                    }
356

357
                    $this->queueSend($userid, $notif['type'], $params, $notif['subscription'], $payload);
2✔
358
                    #error_log("Queued send {$notif['type']} for $userid");
359
                    $count++;
2✔
360
                }
361
            }
362
        }
363

364
        return ($count);
596✔
365
    }
366

367
    public function notifyGroupMods($groupid)
368
    {
369
        $count = 0;
106✔
370
        $mods = $this->dbhr->preQuery("SELECT DISTINCT userid FROM memberships WHERE groupid = ? AND role IN ('Owner', 'Moderator');",
106✔
371
            [$groupid]);
106✔
372

373
        foreach ($mods as $mod) {
106✔
374
            $u = User::get($this->dbhr, $this->dbhm, $mod['userid']);
32✔
375
            $settings = $u->getGroupSettings($groupid);
32✔
376

377
            if (!array_key_exists('pushnotify', $settings) || $settings['pushnotify']) {
32✔
378
                #error_log("Notify {$mod['userid']} for $groupid notify " . Utils::presdef('pushnotify', $settings, TRUE) . " settings " . var_export($settings, TRUE));
379
                $count += $this->notify($mod['userid'], TRUE);
32✔
380
            }
381
        }
382

383
        return ($count);
106✔
384
    }
385

386
    public function pokeGroupMods($groupid, $data)
387
    {
388
        $count = 0;
32✔
389
        $mods = $this->dbhr->preQuery("SELECT userid FROM memberships WHERE groupid = ? AND role IN ('Owner', 'Moderator');",
32✔
390
            [$groupid]);
32✔
391

392
        foreach ($mods as $mod) {
32✔
393
            $this->poke($mod['userid'], $data, TRUE);
19✔
394
            $count++;
19✔
395
        }
396

397
        return ($count);
32✔
398
    }
399

400
    public function fsockopen($host, $port, &$errno, &$errstr)
401
    {
UNCOV
402
        $fp = @fsockopen($host, $port, $errno, $errstr);
×
UNCOV
403
        return ($fp);
×
404
    }
405

406
    public function fputs($fp, $str)
407
    {
UNCOV
408
        return (fputs($fp, $str));
×
409
    }
410

411
    public function poke($userid, $data, $modtools)
412
    {
413
        # We background this as it hits another server, so it may be slow (especially if that server is sick).
414
        try {
415
            $this->uthook();
122✔
416

417
            if (!$this->pheanstalk) {
122✔
418
                $this->pheanstalk = Pheanstalk::create(PHEANSTALK_SERVER);
122✔
419
            }
420

421
            $str = json_encode(array(
122✔
422
                'type' => 'poke',
122✔
423
                'queued' => microtime(TRUE),
122✔
424
                'groupid' => $userid,
122✔
425
                'data' => $data,
122✔
426
                'modtools' => $modtools,
122✔
427
                'ttr' => Utils::PHEANSTALK_TTR
122✔
428
            ));
122✔
429

430
            $this->pheanstalk->put($str);
122✔
431
            $ret = TRUE;
122✔
432
        } catch (\Exception $e) {
1✔
433
            error_log("poke Beanstalk exception " . $e->getMessage());
1✔
434
            $this->pheanstalk = NULL;
1✔
435
            $ret = FALSE;
1✔
436
        }
437

438
        return ($ret);
122✔
439
    }
440

441
    public function executePoke($userid, $data, $modtools)
442
    {
443
        # This kicks a user who is online at the moment with an outstanding long poll.
444
        Utils::filterResult($data);
1✔
445

446
        # We want to POST to notify.  We can speed this up using a persistent socket.
447
        $service_uri = "/publish?id=$userid";
1✔
448

449
        $topdata = array(
1✔
450
            'text' => $data,
1✔
451
            'channel' => $userid,
1✔
452
            'modtools' => $modtools,
1✔
453
            'id' => 1
1✔
454
        );
1✔
455

456
        $vars = json_encode($topdata);
1✔
457

458
        $header = "Host: " . CHAT_HOST . "\r\n";
1✔
459
        $header .= "User-Agent: Iznik Notify\r\n";
1✔
460
        $header .= "Content-Type: application/json\r\n";
1✔
461
        $header .= "Content-Length: " . strlen($vars) . "\r\n";
1✔
462
        $header .= "Connection: close\r\n\r\n";
1✔
463

464
        # Currently we don't do anything with these.  We used to send them to an nchan instance,
465
        # but that was no longer reliable and the clients don't use them.
466
        if (CHAT_HOST && FALSE) {
1✔
467
            try {
468
                #error_log("Connect to " . CHAT_HOST . " port " . CHAT_PORT);
UNCOV
469
                $fp = $this->fsockopen('ssl://' . CHAT_HOST, CHAT_PORT, $errno, $errstr, 2);
×
470

UNCOV
471
                if ($fp) {
×
UNCOV
472
                    if (!$this->fputs($fp, "POST $service_uri  HTTP/1.1\r\n")) {
×
473
                        # This can happen if the socket is broken.  Just close it ready for next time.
UNCOV
474
                        fclose($fp);
×
UNCOV
475
                        error_log("Failed to post");
×
476
                    } else {
UNCOV
477
                        fputs($fp, $header . $vars);
×
UNCOV
478
                        $server_response = fread($fp, 512);
×
UNCOV
479
                        fclose($fp);
×
480
                        #error_log("Rsp on $service_uri $server_response");
481
                    }
482
                }
UNCOV
483
            } catch (\Exception $e) {
×
UNCOV
484
                error_log("Failed to notify");
×
485
            }
486
        }
487
    }
488
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc